Compare commits
17 Commits
v0.3.0
..
5fe4cdf7f8
| Author | SHA1 | Date | |
|---|---|---|---|
| 5fe4cdf7f8 | |||
| 369555efc7 | |||
| 360a1f7e3d | |||
| c0619a55ab | |||
| 09280dd410 | |||
| c4fd4eb3dd | |||
| e0a2e1f4bf | |||
| 504ed3e1cb | |||
| d6e438a541 | |||
| a2fb95e57b | |||
| 581af95e7b | |||
| 824b4a2506 | |||
| 920842a465 | |||
| 3be310ade6 | |||
| 2d8fb49bf5 | |||
| 1be0bebda2 | |||
| 2b52b818e8 |
@@ -0,0 +1,2 @@
|
||||
* text=auto
|
||||
*.sh text eol=lf
|
||||
@@ -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
|
||||
|
||||
@@ -3,16 +3,22 @@
|
||||
|
||||
# Game assets are not included in the repo
|
||||
/res/assets/setup_system_shock_enhanced*
|
||||
/res/assets/setup_system_shock_classic*
|
||||
/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
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
# Persistent configuration (`questshock.config.json`)
|
||||
|
||||
QuestShock can load a persistent, human-editable configuration file at
|
||||
startup, in addition to (and independent of) the engine's own `prefs.txt`.
|
||||
|
||||
- **Location**: `/sdcard/questshock/questshock.config.json` - the same
|
||||
folder you already copy `res/data`/`res/sound` into (see README.md
|
||||
section 5.1).
|
||||
- **Format**: plain JSON, grouped by concern. Each top-level key is a
|
||||
*group*; each key inside a group is one *option*.
|
||||
- **Loading**: read automatically at app startup, before the splash screen,
|
||||
if the file exists. If it doesn't exist, nothing changes - the engine
|
||||
behaves exactly as it did before this file existed (driven by `prefs.txt`
|
||||
as usual).
|
||||
- **Saving**: never automatic. In-headset, open the menu (controller menu
|
||||
button) and press **Save Configuration** to write the file from your
|
||||
current live settings, overwriting it if present. Press **Load
|
||||
Configuration** to re-read the file and apply it without restarting the
|
||||
app.
|
||||
- **Live vs. restart-required**: every option below is tagged as either
|
||||
*live-appliable* (applying it while already playing takes effect
|
||||
immediately) or *restart-required*. Pressing **Load Configuration**
|
||||
applies every live-appliable change straight away; if the file also
|
||||
changes something restart-required, a dialog lists exactly which
|
||||
option(s) need it and offers:
|
||||
- **Dismiss** - keep playing with the live-appliable changes already
|
||||
applied; the restart-required option(s) stay as they were.
|
||||
- **Restart Now** - relaunches the app, which re-runs this same startup
|
||||
load and so applies every group in the file, including the
|
||||
restart-required ones.
|
||||
|
||||
(Every option currently documented below is live-appliable, so this
|
||||
dialog won't actually appear yet - it's built in for options added in a
|
||||
future version that do need a restart.)
|
||||
- Unrecognized groups/keys are ignored (harmless if you're editing the file
|
||||
in advance for a future version). An invalid value for a *specific*
|
||||
option (see each option's possible values below) is logged and that one
|
||||
option is skipped - every other valid option in the file still applies.
|
||||
A file that isn't valid JSON at all is ignored entirely, with nothing
|
||||
applied.
|
||||
- `questshock.config.json` and `prefs.txt` are fully independent - loading
|
||||
or saving one never reads or writes the other.
|
||||
|
||||
## Example
|
||||
|
||||
```json
|
||||
{
|
||||
"video_options": {
|
||||
"renderer": "opengl",
|
||||
"resolution": "1024x768"
|
||||
},
|
||||
"general": {
|
||||
"language": "english"
|
||||
},
|
||||
"player_position": {
|
||||
"x": 0.0,
|
||||
"z": 0.0,
|
||||
"yaw_degrees": 0.0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Video Options (`video_options`)
|
||||
|
||||
### `renderer`
|
||||
|
||||
Which renderer the game uses to draw the 3D view.
|
||||
|
||||
- **Type**: enum
|
||||
- **Possible values**: `"software"`, `"opengl"`
|
||||
- **Restart required?**: No (live-appliable)
|
||||
|
||||
### `resolution`
|
||||
|
||||
The game's internal rendering resolution.
|
||||
|
||||
- **Type**: enum
|
||||
- **Possible values**: `"320x200"`, `"320x400"`, `"640x400"`, `"640x480"`, `"1024x768"`
|
||||
- **Restart required?**: No (live-appliable)
|
||||
|
||||
## General (`general`)
|
||||
|
||||
### `language`
|
||||
|
||||
The game's UI/text language - both the game's own resource strings and this
|
||||
project's own menu/keyboard chrome text (title bar, buttons, etc.).
|
||||
|
||||
- **Type**: enum
|
||||
- **Possible values**: `"english"`, `"french"`, `"german"`
|
||||
- **Restart required?**: No (live-appliable) - applies the same way the
|
||||
game's own in-game options menu applies a language change (reloads the
|
||||
language-specific resource files and refreshes the inventory/MFD/side-icon
|
||||
UI immediately).
|
||||
- **Note**: French and German both need their own full resource pack
|
||||
(`frnstrng.res`/`mfdfrn.res`, `gerstrng.res`/`mfdger.res` under
|
||||
`res/data/`) - if any of those files aren't present, the switch is
|
||||
refused (logged, `questshock.config.json` and the current language are
|
||||
left unchanged) rather than leaving the game with no valid resource file
|
||||
open. See README.md section 3 ("Game assets") for where `mfdfrn.res`/
|
||||
`mfdger.res` come from - the GOG Enhanced Edition installer alone doesn't
|
||||
include them.
|
||||
|
||||
## Player Position (`player_position`)
|
||||
|
||||
Where the player is virtually standing/facing, independent of - and not
|
||||
reset by - starting a new game or loading a save (same independence from
|
||||
`prefs.txt`/savegames as every other group here). Set by moving around
|
||||
in-headset (menu open, left thumbstick snap-turns, right thumbstick smooth-
|
||||
moves) to line yourself up with the game quad ("virtual screen"). Height is
|
||||
never included here - it always comes from your real tracked headset
|
||||
height, not this file.
|
||||
|
||||
**Startup fallback**: only applied at startup if all three fields (`x`,
|
||||
`z`, `yaw_degrees`) are present - a config file with none of them (or none
|
||||
at all), or a partial/hand-edited one missing any of the three, falls back
|
||||
to an automatic recenter instead (see **Reset Position** below), using
|
||||
your real head pose at that moment. A complete saved position is applied
|
||||
as-is, with no such recenter.
|
||||
|
||||
**Reset Position** (Main Menu tab) is a separate, live-only action, not a
|
||||
config value: it recenters using your *current* real head pose, so the
|
||||
game quad ends up directly ahead of wherever you're actually standing/
|
||||
facing right now - not a plain "go back to `(0, 0, 0)`" (which would only
|
||||
undo thumbstick movement, not any physical drift/rotation since the
|
||||
session started). It's unrelated to `questshock.config.json` - it never
|
||||
reads or writes this file.
|
||||
|
||||
### `x`, `z`
|
||||
|
||||
Horizontal position, in meters, on the play space's floor plane.
|
||||
|
||||
- **Type**: number
|
||||
- **Restart required?**: No (live-appliable)
|
||||
|
||||
### `yaw_degrees`
|
||||
|
||||
Which way the player is virtually facing, in degrees.
|
||||
|
||||
- **Type**: number
|
||||
- **Restart required?**: No (live-appliable)
|
||||
@@ -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
|
||||
ENGINE_OUT := engine/.build-output
|
||||
ASSETS_DIR := res/assets/ss_ee
|
||||
BUILD_DIR := build
|
||||
ARCH := $(shell uname -m)
|
||||
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 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
|
||||
@@ -108,8 +177,23 @@ apk:
|
||||
./run-image.sh bash build-image/build-apk.sh; \
|
||||
fi
|
||||
|
||||
# Stages a scratch, patched copy of engine/ and android/gl4es-src/, plus
|
||||
# the Android SDL2/SDL2_mixer/fluidsynth-lite/openxr prebuilts, with
|
||||
# host-resolvable paths (writes android/engine.properties) so Android
|
||||
# Studio can compile and deploy android/ natively - see README's "5.2.
|
||||
# Building natively in Android Studio". `make apk` doesn't need this; it
|
||||
# stages the same things itself inside the build-image. Re-run after
|
||||
# `make clean` (which deletes android/engine.properties) or after
|
||||
# changing engine/, android/engine-patches/, android/gl4es-src/,
|
||||
# android/gl4es-patches/, or the prebuilt-library versions in
|
||||
# build-image/Dockerfile - Android Studio's own Gradle build already
|
||||
# does this automatically via its stageEngine task, so this is mainly
|
||||
# useful for first-time setup or confirming staging succeeded on its own.
|
||||
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,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.)
|
||||
|
||||
@@ -52,6 +52,11 @@ a cross-platform port of the original game.
|
||||
Android-based OpenXR headsets. Unlike every other target, this needs
|
||||
network access at build time (Gradle/AGP's own dependency
|
||||
resolution). See "5. Android / Quest build" below.
|
||||
- `android-studio` - stages `engine/`/`android/gl4es-src/` (scratch,
|
||||
patched copies) and the Android prebuilts with host-resolvable paths,
|
||||
so Android Studio can compile and deploy `android/` natively instead
|
||||
of inside the build-image. See "5.2. Building natively in Android
|
||||
Studio" below.
|
||||
- `clean` - removes all build output (`dist/`, `build/`, compiled
|
||||
engine artifacts, and the staged Android project files).
|
||||
- `android/` - the Quest app (Java `SDLActivity` glue, Gradle project).
|
||||
@@ -85,12 +90,47 @@ package`/`make apk` pick it up the same way either way. This one copy of
|
||||
game assets feeds both the desktop build and the Android/Quest build
|
||||
below.
|
||||
|
||||
**Note on French/German**: the GOG Enhanced Edition installer only ships
|
||||
English MFD (Multi-Function Display - the small in-game screens showing
|
||||
the map, inventory, etc.) art resources (`mfdart.res`) - it doesn't include
|
||||
`mfdfrn.res`/`mfdger.res`, which French/German need in addition to their
|
||||
own text resources (`frnstrng.res`/`gerstrng.res`, which the installer
|
||||
*does* include). Without those two files, switching to French/German (via
|
||||
`questshock.config.json`'s `general.language` or the in-headset menu's
|
||||
Language row - see [CONFIG.md](CONFIG.md)) is refused with an in-headset
|
||||
popup rather than applied, since doing so anyway used to crash the game. If
|
||||
you also own **System Shock: Classic** on gog.com (which does include both
|
||||
files), drop its offline installer into `res/assets/` alongside the
|
||||
Enhanced Edition one and `extract_assets.sh` pulls them out of it too,
|
||||
entirely optionally - it still runs fine with just the Enhanced Edition
|
||||
installer.
|
||||
|
||||
## 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)
|
||||
@@ -99,11 +139,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/`
|
||||
@@ -112,14 +153,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
|
||||
@@ -128,6 +193,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
|
||||
@@ -135,12 +212,62 @@ can be sideloaded onto any Android-based VR headset with OpenXR support
|
||||
vendor's store. The game's own rendering is unchanged (still a flat, 2D
|
||||
render, no stereo 3D scene), but instead of running as a Home-hosted 2D
|
||||
panel it's now shown as a single head-tracked quad floating in front of
|
||||
the viewer 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. (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
|
||||
the viewer, with a separate menu quad toggled by a controller button and
|
||||
driven by a laser-pointer-style aim ray from each hand
|
||||
(`android/app/src/main/cpp/xr_input.c`) - point and pull the trigger to
|
||||
interact with it, same as the game's own Bluetooth mouse/keyboard input
|
||||
otherwise works unchanged.
|
||||
|
||||
The menu launcher (`MenuOverlay.java` - just a "Keyboard" button so far)
|
||||
and the keyboard (`KeyboardOverlay.java`) are two fully independent quads,
|
||||
each its own `XrOverlay` instance (`android/app/src/main/cpp/xr_overlay.c`
|
||||
- a generic "off-screen Android View rendered into an OpenXR quad,
|
||||
hit-tested via a laser pointer" module shared by both, and by whatever
|
||||
similar panel comes next) with its own swapchain, visibility, and
|
||||
position, so they can be shown/hidden/moved independently - a design
|
||||
specifically meant to let the keyboard stay open while also picking
|
||||
something from the (still-to-be-built-out) menu. The controller's menu
|
||||
button only ever opens/closes the launcher; clicking its "Keyboard" button
|
||||
only opens the keyboard (never touching the launcher's own visibility) -
|
||||
the keyboard's own title bar holds the only way to close it again.
|
||||
|
||||
The keyboard is a hand-built, full US-layout key grid (there's no system
|
||||
IME to borrow once immersive) that forwards each key press straight to the
|
||||
game as real input - non-printable keys (Esc/Enter/Backspace/Tab/arrows/
|
||||
F1-F12) as a `SDLActivity.onNativeKeyDown()`/`onNativeKeyUp()` pair, the
|
||||
same one a physical Bluetooth keyboard's presses already go through, and
|
||||
printable keys (letters, digits, punctuation, Space) via a synthesized
|
||||
`SDL_TEXTINPUT` event pushed directly from native code
|
||||
(`questshock_native.c`). A Shift key toggles the whole grid between
|
||||
lowercase/numbers and uppercase/symbols (doubling as Caps Lock - it stays
|
||||
toggled until pressed again), and Ctrl/Alt arm as one-shot modifiers,
|
||||
consumed by whichever key is pressed next - meant as a general stand-in
|
||||
for keyboard-driven functionality that isn't (yet, or ever) mapped onto
|
||||
the controllers, not just a future config screen's text entry.
|
||||
|
||||
Once opened, the keyboard stays up as a standing input panel while
|
||||
actually playing rather than a modal you open and close. Its title bar
|
||||
doubles as a drag handle (grab-and-drag with the trigger, handled entirely
|
||||
on the native side in `xr_input.c` before it ever reaches
|
||||
`KeyboardOverlay`'s own touch dispatch) for repositioning it, and holds
|
||||
the Close button that hides it. It's also rendered semi-transparent
|
||||
(`XR_COMPOSITION_LAYER_BLEND_TEXTURE_SOURCE_ALPHA_BIT` in `xr_session.c`,
|
||||
set only on the keyboard's layer - the menu launcher stays fully opaque)
|
||||
so whatever's behind it - the game quad, mid-play - stays visible while
|
||||
it's up.
|
||||
|
||||
While the menu launcher or keyboard is open, each hand also gets a thin,
|
||||
colored laser-beam quad (cyan left, amber right) from the controller to
|
||||
wherever its ray currently crosses that panel's plane - a second,
|
||||
billboarded `XrCompositionLayerQuad` per hand (`xr_input_build_beam()` in
|
||||
`xr_input.c`), oriented so it reads as a line from the viewer's eye
|
||||
regardless of angle, since the flat cross-shaped reticle used while
|
||||
aiming at the game quad is drawn directly into the game quad's own
|
||||
texture and can't represent a ray traversing real 3D space. Aiming at the
|
||||
game quad itself still uses that flat reticle, unchanged. (Only tested on
|
||||
Meta Quest so far -
|
||||
the steps below use Quest-specific tool names where relevant, but the
|
||||
same `adb install` flow applies to any Android headset with USB
|
||||
debugging enabled.)
|
||||
|
||||
### 5.1. Installing and playing
|
||||
@@ -158,6 +285,11 @@ debugging enabled.)
|
||||
`/sdcard/questshock/res/`.
|
||||
4. Launch it again.
|
||||
|
||||
`/sdcard/questshock/` also holds an optional persistent settings file,
|
||||
`questshock.config.json`, loaded automatically at startup and saved/loaded
|
||||
from the in-headset menu - see [CONFIG.md](CONFIG.md) for the documented
|
||||
options.
|
||||
|
||||
### 5.2. Building natively in Android Studio
|
||||
|
||||
`make apk` always compiles the engine and links the APK inside the
|
||||
@@ -168,11 +300,12 @@ 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
|
||||
make android-studio
|
||||
```
|
||||
|
||||
This stages everything `make apk` normally stages (scratch, patched copies
|
||||
of `engine/` and `android/gl4es-src/`; the Android
|
||||
(equivalent to `./run-image.sh bash build-image/prepare-android-project.sh
|
||||
--host-paths` directly.) This stages everything `make apk` normally stages
|
||||
(scratch, patched copies of `engine/` and `android/gl4es-src/`; the Android
|
||||
SDL2/SDL2_mixer/fluidsynth-lite/openxr prebuilts; bundled assets) - the
|
||||
same as `build-apk.sh`'s own prep step - except it writes
|
||||
`android/engine.properties` with paths that resolve on your host
|
||||
@@ -184,12 +317,13 @@ Studio's own native build (a CMake subdirectory of `engine/`'s build, not
|
||||
a separate prebuilt step), so `android/gl4es-src/`/`android/gl4es-patches/`
|
||||
changes are picked up by a normal rebuild here - no need to re-run
|
||||
`./build-image.sh` first, unlike `build-image/Dockerfile` changes. Re-run
|
||||
this script whenever `engine/`, `android/engine-patches/`,
|
||||
`make android-studio` whenever `engine/`, `android/engine-patches/`,
|
||||
`android/gl4es-src/`, `android/gl4es-patches/`, or the prebuilt-library
|
||||
versions in `build-image/Dockerfile` change (the Gradle build already
|
||||
does this automatically for you via its `stageEngine` task, so this
|
||||
manual re-run is mainly useful for confirming staging succeeded on its
|
||||
own).
|
||||
own) - and always after `make clean`, which deletes
|
||||
`android/engine.properties` along with everything else.
|
||||
|
||||
Then, in Android Studio, use File > Open and select the `android/`
|
||||
directory itself (the one containing `settings.gradle` - not the repo
|
||||
@@ -226,13 +360,20 @@ the same as any other Android Studio project:
|
||||
`QuestShock`, so filtering Logcat by that tag shows everything
|
||||
questshock-specific without gl4es/OpenXR loader/system noise (drop
|
||||
the filter to see those too).
|
||||
6. Only re-run `prepare-android-project.sh` manually (see above) after
|
||||
6. Only re-run `make android-studio` manually (see above) after
|
||||
changing `engine/`, `android/engine-patches/`, `android/gl4es-src/`,
|
||||
`android/gl4es-patches/`, or the prebuilt-library versions in
|
||||
`build-image/Dockerfile` - the Gradle build's `stageEngine` task does
|
||||
this automatically otherwise, so plain Java/C++ edits under
|
||||
`android/app/src/main/` just need another Run/Debug press.
|
||||
|
||||
**If a native change genuinely doesn't seem to take effect** even after a
|
||||
normal rebuild - `stageEngine` can wrongly report `UP-TO-DATE` for a
|
||||
newly-added file under `android/engine-patches/` - run
|
||||
`android/force-rebuild.sh` and rebuild. For more visibility than Android
|
||||
Studio's collapsed Build panel, run `./gradlew :app:assembleDebug
|
||||
--console=plain` from a terminal instead.
|
||||
|
||||
### 5.4. Engine/gl4es patch reference
|
||||
|
||||
`android/engine-patches/` (applied to a scratch copy of `engine/`) and
|
||||
@@ -262,10 +403,14 @@ each one does:
|
||||
emulation) for real GLES-native equivalents: a custom `pointSize`
|
||||
uniform, a shader-side alpha discard, and native point-sprite
|
||||
rasterization.
|
||||
- **`05-android-audio-driver.patch`** - forces SDL2's older OpenSL ES
|
||||
audio backend instead of AAudio, because AAudio only allows one open
|
||||
playback device and the engine opens two (cutscene audio plus
|
||||
`Mix_OpenAudio` for SFX/MIDI).
|
||||
- **`05-android-audio-driver.patch`** - forces SDL2's OpenSL ES audio
|
||||
backend instead of AAudio via
|
||||
`SDL_SetHintWithPriority(..., SDL_HINT_OVERRIDE)` (so nothing else can
|
||||
take precedence and silently leave AAudio selected), and logs the
|
||||
actual `SDL_GetCurrentAudioDriver()` result right after `SDL_Init()`
|
||||
as a standing on-device diagnostic. Does not by itself fix SFX/MIDI
|
||||
playback - see `16-android-audio-single-device.patch` and README 5.5.3
|
||||
for the actual cause and fix.
|
||||
- **`06-android-resize-event.patch`** - makes the engine react to
|
||||
`SDL_WINDOWEVENT_RESIZED`, not just `SIZE_CHANGED`, since Android's SDL
|
||||
backend only ever sends the former for surface-driven resizes.
|
||||
@@ -277,13 +422,117 @@ each one does:
|
||||
`SDL_SetWindowFullscreen`/`SetWindowSize`/`SetWindowPosition` calls on
|
||||
Android, since there's no real desktop-style window to resize and
|
||||
calling them desyncs SDL's cached window size from the real surface.
|
||||
Also calls `opengl_resize()` directly in `ChangeScreenSize()`
|
||||
(see README 5.5.6) - skipping `SDL_SetWindowSize()` means no native
|
||||
resize event ever fires on Android to trigger it when the in-game
|
||||
resolution changes, so it would otherwise never re-run there.
|
||||
- **`10-android-openxr-cmake.patch`** - wires up the OpenXR loader plus
|
||||
EGL as link/include dependencies for the CMake build.
|
||||
- **`11-android-openxr-present.patch`** - the core OpenXR present-path
|
||||
hook: redirects `SDLDraw()`/`opengl_swap_and_restore()` to submit into
|
||||
the XR swapchain (via `xr_frame_begin()`/`xr_frame_end()`) instead of
|
||||
the normal window, for both the GL-rendered 3D path and the plain
|
||||
software-composited path (splash screen/cutscenes/menus).
|
||||
software-composited path (splash screen/cutscenes/menus). Also logs
|
||||
once per transition between the two (`INFO("Game rendering active:
|
||||
...")`) for on-device logcat diagnostics.
|
||||
- **`12-android-texture-row-length.patch`** - `OpenGL.cc`'s wall/floor
|
||||
texture upload (`opengl_cache_texture()`/`set_texture()`, only reachable
|
||||
when `gShockPrefs.doUseOpenGL` is true - it defaults `false` in
|
||||
`Prefs.c`, so this path is dormant on Quest by default) sets
|
||||
`GL_UNPACK_ROW_LENGTH` from the original 8-bit source bitmap's row
|
||||
stride (`bm->row`), but the buffer actually uploaded is a separately-
|
||||
allocated RGBA32 conversion of it with its own, unrelated stride. Fixed
|
||||
to use the converted buffer's own pitch instead.
|
||||
- **`13-android-swapchain-sync.patch`** - `android_draw_surface_as_quad()`
|
||||
used `glFlush()` after drawing into the swapchain's FBO, which queues
|
||||
the GPU work without waiting for it to finish before
|
||||
`xr_swapchain_release()` hands the image to the OpenXR runtime for
|
||||
cross-process consumption. Changed to `glFinish()`, which blocks until
|
||||
the write has landed. The same gap existed in `xr_overlay.c`'s
|
||||
`xr_overlay_render_and_build_layer()` (the keyboard/menu overlay blit
|
||||
path) and was fixed there directly, no patch needed since that file is
|
||||
first-party.
|
||||
- **`14-android-quad-unpack-state.patch`** - fixes the diagonal-banded/
|
||||
skewed corruption in the 3D game view during real gameplay (see README
|
||||
5.5.2). `android_draw_surface_as_quad()`'s `glTexImage2D` upload of the
|
||||
tightly-packed `uiRgba` buffer relied on GL's default unpack state
|
||||
(`GL_UNPACK_ROW_LENGTH`/`GL_UNPACK_ALIGNMENT` at 0/4) instead of setting
|
||||
it explicitly. gl4es implements both client-side and persists whatever
|
||||
was last set across draw calls in the same context, so this upload
|
||||
inherited non-default unpack state left behind elsewhere in the frame
|
||||
and misread every row. Fixed by resetting both to the packed defaults
|
||||
immediately before this upload.
|
||||
- **`15-android-audio-error-detail.patch`** - `snd_start_digital()`'s
|
||||
`Mix_OpenAudio()` failure only logged a fixed string, not the actual
|
||||
reason. Adds `SDL_GetError()` to the log line so the next on-device
|
||||
failure reports the real underlying error.
|
||||
- **`16-android-audio-single-device.patch`** - fixes SFX/MIDI music not
|
||||
playing (see README 5.5.3). `snd_start_digital()` opened two SDL audio
|
||||
devices: a raw `SDL_OpenAudioDevice()` for cutscene/audiolog audio and
|
||||
a second one via `Mix_OpenAudio()` for SFX/MIDI - SDL's Android audio
|
||||
backends only ever allow one open non-capture device, so the second
|
||||
open always failed. Removes the raw device entirely and feeds cutscene/
|
||||
audiolog PCM into the `Mix_OpenAudio()` device's output instead, via
|
||||
`Mix_SetPostMix()` (mixing in rather than overwriting, so it doesn't
|
||||
silence any SFX/MIDI music playing at the same time). `cutsloop.c`'s
|
||||
and `audiolog.c`'s `SDL_PauseAudioDevice()` calls (no longer meaningful
|
||||
without a dedicated device) are removed; the cutscene-audio-stream
|
||||
pointer swaps they guarded are now protected by `SDL_LockAudio()`/
|
||||
`SDL_UnlockAudio()` instead, since SDL_mixer's audio thread now reads
|
||||
that pointer continuously via the post-mix hook.
|
||||
- **`17-android-native-midi-unsupported.patch`** - `NativeMidiInit()` in
|
||||
`MusicDevice.c` has real implementations only under `#ifdef WIN32` and
|
||||
`#elif defined(USE_ALSA)`; on every other platform (Android included)
|
||||
it fell through to `return 0` (success) without ever opening a device,
|
||||
so a `midi-backend` preference of "Native" (see README 5.5.4) played
|
||||
silently forever instead of falling back. Adds an `#else` branch that
|
||||
returns failure and logs a `WARN`, so `InitDecXMI()`'s existing
|
||||
fallback-to-dummy-driver logic actually triggers.
|
||||
- **`18-android-opengl-immediate-mode-attribs.patch`** - fixes a black (and,
|
||||
after this patch's first revision, flat-colored/untextured) screen when
|
||||
switching to the OpenGL/3D render mode in-game (see README 5.5.5). Every
|
||||
`glBegin(GL_TRIANGLE_STRIP)`-based draw in `OpenGL.cc` -
|
||||
`opengl_swap_and_restore()`'s screen-composite quad,
|
||||
`opengl_light_tmap()` (every textured wall/floor polygon, via
|
||||
`draw_vertex()`), `opengl_bitmap()` (sprites), and `opengl_draw_poly()`
|
||||
(flat/gouraud polygons, also via `draw_vertex()`) - used
|
||||
`glVertexAttrib2f`/`glVertexAttrib1f`/`glVertex3f` immediate mode to set
|
||||
per-vertex texcoords/light, which gl4es only captures per-vertex for
|
||||
attribute 0 (position); custom attributes end up constant for the whole
|
||||
draw call. Adds `android_imm_begin()`/`android_imm_vertex()`/
|
||||
`android_imm_end()`, a small shared immediate-mode-emulation shim that
|
||||
buffers vertices and flushes them as one real `glDrawArrays()` call, and
|
||||
switches every one of those call sites to it, Android-only. The
|
||||
starfield (`opengl_begin_stars()`/`opengl_draw_star()`/
|
||||
`opengl_end_stars()`, `GL_POINTS` rather than `GL_TRIANGLE_STRIP`, up to
|
||||
`NUM_STARS`=1000 per frame) had the same bug for its per-star brightness
|
||||
and gets its own similarly-shaped `android_stars_begin()`/
|
||||
`android_stars_vertex()`/`android_stars_end()` buffer. Also skips the
|
||||
unconditional `SDL_GL_MakeCurrent(window, context)` call every
|
||||
rendering function in this file had at its top, Android-only - a
|
||||
worthwhile cleanup on its own merits, but *not* the source of the
|
||||
persistent per-frame `GL_INVALID_OPERATION` (1282) logcat spam during
|
||||
3D gameplay that this was first suspected to be (see README 5.5.8 for
|
||||
that bug's actual root cause and fix, in patch 20 below).
|
||||
- **`19-android-fix-hackcam-opengl-frame.patch`** - `fr_start_view()`/
|
||||
`fr_send_view()` (`frsetup.c`) called `opengl_start_frame()`/
|
||||
`opengl_end_frame()` again for hack-camera security-monitor renders
|
||||
(which target a private software bitmap, not the GL-backed screen),
|
||||
since `should_opengl_swap()` doesn't exclude them the way
|
||||
`use_opengl()` already does. Adds the same exclusion to both call
|
||||
sites (see README 5.5.8).
|
||||
- **`20-android-no-default-framebuffer.patch`** - the actual fix for the
|
||||
persistent per-frame `GL_INVALID_OPERATION` (1282) in README 5.5.8:
|
||||
`opengl_end_frame()`'s `BindFrameBuffer(nullptr)` bind to framebuffer/
|
||||
renderbuffer id 0 has no valid target on Android (real presentation
|
||||
goes through the OpenXR swapchain's own FBOs, set up separately in
|
||||
`xr_swapchain.c`) and is also functionally dead there regardless -
|
||||
skipped entirely, Android-only.
|
||||
- **`21-android-config.patch`** - hooks up the persistent JSON config file
|
||||
(see [CONFIG.md](CONFIG.md), `qs_config.c`): one call to
|
||||
`qs_config_load_startup()` in `Shock.c`'s `main()`, right after
|
||||
`LoadPrefs()`, and one check in `sdl_events.c`'s `pump_events()` to drain
|
||||
the custom SDL events the menu's Save/Load Configuration buttons enqueue.
|
||||
|
||||
`android/gl4es-patches/`:
|
||||
|
||||
@@ -299,8 +548,9 @@ each one does:
|
||||
|
||||
#### 5.5.1. The menu quad rendering the game instead of itself
|
||||
|
||||
While building the OpenXR menu (`android/app/src/main/cpp/xr_menu.c`,
|
||||
`MenuOverlay.java`), the menu's composition-layer quad consistently showed
|
||||
While building the OpenXR menu (`android/app/src/main/cpp/xr_overlay.c` -
|
||||
`xr_menu.c` at the time - and `MenuOverlay.java`), the menu's
|
||||
composition-layer quad consistently showed
|
||||
the game's own live rendering instead of the menu's content, even though
|
||||
every diagnostic (FBO bindings, swapchain/layer submission, texture
|
||||
upload, viewport/scissor state) checked out correct in isolation.
|
||||
@@ -344,6 +594,264 @@ trigger (a single always-nonzero struct field) sits well below the
|
||||
project's own layer boundary and doesn't show up in any of the local GL
|
||||
state that the menu's own code controls.
|
||||
|
||||
#### 5.5.2. Diagonal-banded corruption in the 3D game view
|
||||
|
||||
During real 3D gameplay (not menus/splash), the game quad rendered as
|
||||
diagonally-banded color noise. Every OpenXR overlay quad (menu, keyboard,
|
||||
laser-beam pointers) and the desktop build's software renderer were
|
||||
unaffected, narrowing the bug to the one present path gameplay uses that
|
||||
those don't: `android_draw_surface_as_quad()` in `engine/src/MacSrc/
|
||||
OpenGL.cc`.
|
||||
|
||||
**Root cause:** its `glTexImage2D` upload of the tightly-packed `uiRgba`
|
||||
conversion buffer relies on GL's default unpack state
|
||||
(`GL_UNPACK_ROW_LENGTH`/`GL_UNPACK_ALIGNMENT` at 0/4) instead of setting
|
||||
it explicitly. gl4es - the GLES/EGL translation layer this engine's
|
||||
desktop-style GL calls go through on Quest - implements both unpack
|
||||
parameters in software, manually repacking the source buffer row-by-row
|
||||
using whatever stride was last set via `glPixelStorei`, and that state
|
||||
persists across draw calls in the same GL context rather than resetting
|
||||
per call. During gameplay, this upload inherits non-default unpack state
|
||||
left behind elsewhere in the frame and misreads every row - a diagonal
|
||||
shear/banding pattern. Menus never trigger it because nothing in the
|
||||
menu-only code paths sets a custom unpack stride first.
|
||||
|
||||
#### 5.5.3. In-game SFX and MIDI music didn't play
|
||||
|
||||
Intro cutscene audio played correctly, but in-game SFX and MIDI/background
|
||||
music never played, on-device.
|
||||
|
||||
**Root cause:** `snd_start_digital()` in `engine/src/MacSrc/SDLSound.c`
|
||||
opened two separate SDL audio devices - a raw `SDL_OpenAudioDevice()`
|
||||
call (used for cutscene/movie audio, always succeeded) and a second
|
||||
device via `Mix_OpenAudio()` (used for SFX and, via `Mix_HookMusic`,
|
||||
MIDI music - always failed, `SDL_GetError()` reporting "Audio device
|
||||
already open"). SDL's Android audio backends (both AAudio and OpenSL ES)
|
||||
only ever allow one open non-capture device - forcing OpenSL ES instead
|
||||
of AAudio (`05-android-audio-driver.patch`) doesn't change this, since
|
||||
the restriction isn't backend-specific.
|
||||
|
||||
**Fix (`16-android-audio-single-device.patch`):** removed the raw
|
||||
`SDL_OpenAudioDevice()` call entirely. Cutscene/audiolog PCM (previously
|
||||
delivered via that device's own callback) is now mixed into the single
|
||||
`Mix_OpenAudio()` device's output via `Mix_SetPostMix()`, additively
|
||||
(`SDL_MixAudioFormat`) rather than overwritten, so SFX/MIDI music
|
||||
already mixed into the buffer isn't silenced whenever a cutscene or
|
||||
audiolog plays. The stream-pointer swaps in `cutsloop.c`/`audiolog.c`
|
||||
that used to be guarded by pausing the now-removed device are guarded by
|
||||
`SDL_LockAudio()`/`SDL_UnlockAudio()` instead, since the post-mix hook
|
||||
runs continuously on SDL_mixer's own audio thread.
|
||||
|
||||
#### 5.5.4. MIDI music silent even after audio otherwise worked
|
||||
|
||||
After fixing 5.5.3, SFX and cutscene audio played correctly, but MIDI
|
||||
background music was still silent, with logcat showing `Creating native
|
||||
MIDI device` instead of `Creating FluidSynth MIDI device`.
|
||||
|
||||
**Root cause:** the engine's `midi-backend` preference (`0` = ADLMIDI,
|
||||
`1` = Native, `2` = FluidSynth) is persisted in a `prefs.txt` file in the
|
||||
app's private storage, which Android retains across every APK
|
||||
reinstall/upgrade (only a full uninstall clears it). A stale value of
|
||||
`1` (Native) from earlier testing was carried forward through every
|
||||
rebuild this whole session. `SetDefaultPrefs()` (`Prefs.c`) does default
|
||||
to `2` (FluidSynth) whenever `USE_FLUIDSYNTH` is defined, which it
|
||||
always has been for this build - the default was never wrong, only the
|
||||
persisted override. `NativeMidiInit()` has no real implementation on
|
||||
Android (see `17-android-native-midi-unsupported.patch`), so it silently
|
||||
reported success without opening anything.
|
||||
|
||||
**Fix:** a full uninstall (not just installing a new build over the old
|
||||
one) clears the stale preference, after which MIDI plays correctly.
|
||||
`17-android-native-midi-unsupported.patch` additionally makes
|
||||
`NativeMidiInit()` report failure on unsupported platforms, so a stale
|
||||
"Native" preference falls back to `InitDecXMI()`'s existing dummy-driver
|
||||
path (still silent, but with a clear `WARN` in logcat) instead of
|
||||
silently reporting success while never opening anything - a much faster
|
||||
diagnosis if this ever recurs.
|
||||
|
||||
#### 5.5.5. Black, then flat-colored/untextured screen after switching to OpenGL rendering in-game
|
||||
|
||||
The engine has a `Ctrl+G`-bound in-game toggle (`toggle_opengl()`,
|
||||
`Prefs.c`) that cycles between the plain software renderer and OpenGL
|
||||
rendering. On Quest, switching to OpenGL rendering (either mode:
|
||||
unfiltered or bilinear) produced a black screen for the 3D view, with
|
||||
`OpenGL error: 1282` (`GL_INVALID_OPERATION`) in logcat right at the switch.
|
||||
|
||||
**Root cause:** `opengl_swap_and_restore()` draws the offscreen-rendered
|
||||
3D view onto a screen-filling quad using `glBegin(GL_TRIANGLE_STRIP)` /
|
||||
`glVertexAttrib2f()`/`glVertexAttrib1f()` / `glVertex3f()` immediate
|
||||
mode to supply per-vertex texcoords and light. This is exactly the same
|
||||
bug already found and fixed for the UI-overlay quad in
|
||||
`11-android-openxr-present.patch`'s `android_draw_surface_as_quad()`:
|
||||
under gl4es's GLES2 immediate-mode emulation, `glVertexAttrib*()` sets a
|
||||
*constant* value for the whole draw call, not a fresh value per vertex
|
||||
the way desktop GL's immediate mode does - only attribute 0 (position,
|
||||
driven by `glVertex3f`) gets real per-vertex capture. The 3D-view quad
|
||||
draw predates that fix and was never updated to match, so it kept using
|
||||
the broken pattern.
|
||||
|
||||
This patch's first revision only fixed that one quad, which did clear the
|
||||
black screen - but the next on-device test showed a "blocky" world
|
||||
instead: every wall/floor polygon rendered as a single solid color
|
||||
instead of its texture. The same broken immediate-mode pattern turned out
|
||||
to be used by every other textured/lit polygon draw in the file too:
|
||||
`opengl_light_tmap()` (every textured wall/floor polygon in the 3D view,
|
||||
via the shared `draw_vertex()` helper), `opengl_bitmap()` (sprites), and
|
||||
`opengl_draw_poly()` (flat/gouraud-shaded polygons, also via
|
||||
`draw_vertex()`) - each polygon's texcoord/light attribute ended up
|
||||
constant across all of its vertices (whatever was last set), instead of
|
||||
varying per vertex, hence the flat, solid-colored blocks.
|
||||
|
||||
**Fix:** `18-android-opengl-immediate-mode-attribs.patch` adds
|
||||
`android_imm_begin()`/`android_imm_vertex()`/`android_imm_end()`, a small
|
||||
shared immediate-mode-emulation shim (buffers vertices, then issues one
|
||||
real `glDrawArrays()` call), and switches every glBegin/glVertexAttrib/
|
||||
glVertex3f call site above to it, Android-only - the desktop path is
|
||||
unaffected and keeps the original immediate-mode code, which works
|
||||
correctly under real desktop OpenGL.
|
||||
|
||||
The same patch also fixes the starfield
|
||||
(`opengl_begin_stars()`/`opengl_draw_star()`/`opengl_end_stars()`), found
|
||||
while auditing the file for every other instance of this pattern rather
|
||||
than through an on-device symptom: each star is a separate `GL_POINTS`
|
||||
vertex with its own brightness set via the same
|
||||
`glVertexAttrib1f`/`glVertex3f` immediate-mode pair, so every star drawn
|
||||
between one begin/end call would have rendered at whatever brightness was
|
||||
set for the last one. Gets its own `android_stars_begin()`/
|
||||
`android_stars_vertex()`/`android_stars_end()` buffer, shaped for
|
||||
`GL_POINTS` and up to `NUM_STARS` (`GameSrc/rendtool.c`) vertices rather
|
||||
than `android_imm_*()`'s small triangle-strip buffer.
|
||||
|
||||
#### 5.5.6. Display breaks and stops responding to input after changing resolution in-game
|
||||
|
||||
Picking a different resolution from the in-game options menu (e.g.
|
||||
`1024x768`) produced a single `OpenGL error: 1282` in logcat and left the
|
||||
display frozen on the last frame - `VrApi`'s FPS counter kept running at
|
||||
90fps (the OpenXR compositor was still submitting frames), but nothing
|
||||
in the menu responded to further input afterward.
|
||||
|
||||
**Root cause:** `ChangeScreenSize()` (`ShockBitmap.c`) calls
|
||||
`SDL_RenderSetLogicalSize()` to the new resolution and reallocates
|
||||
`drawSurface`/`offscreenDrawSurface` accordingly, but on desktop the
|
||||
actual GL-side work - `opengl_resize()` in `OpenGL.cc`, which
|
||||
(re)creates `backupBuffer` (the offscreen render target the 3D view
|
||||
draws into) at the new size - only runs later, from the
|
||||
`SDL_WINDOWEVENT_RESIZED`/`SIZE_CHANGED` event that
|
||||
`SDL_SetWindowSize()` generates. `09-android-no-window-resize.patch`
|
||||
skips that `SDL_SetWindowSize()` call on Android for an unrelated
|
||||
reason (see its own entry above) - which also means no such event, and
|
||||
therefore no `opengl_resize()` call, ever happens on Android when the
|
||||
in-game resolution changes. `backupBuffer` stayed stuck at whatever size
|
||||
it was created with at launch while the rest of the game moved on to
|
||||
the new resolution, and that mismatch is what broke rendering and left
|
||||
the menu unable to correctly hit-test further clicks.
|
||||
|
||||
**Fix:** `ChangeScreenSize()` now calls `opengl_resize()` directly on
|
||||
Android, right after updating `gScreenWide`/`gScreenHigh` (matching the
|
||||
desktop event's actual timing, since `SDL_PollEvent()` always delivers
|
||||
it after `ChangeScreenSize()` has already returned) - added to
|
||||
`09-android-no-window-resize.patch` since this is a direct, foreseeable
|
||||
consequence of that patch's own change, not a new mechanism.
|
||||
|
||||
#### 5.5.7. Laser-pointer cursor disappears after changing resolution in-game
|
||||
|
||||
After 5.5.6's fix, changing resolution stopped breaking the display, but
|
||||
the laser-pointer-driven mouse cursor (see section 5's architecture
|
||||
overview) would disappear and stop being able to click anything
|
||||
afterward.
|
||||
|
||||
**Root cause:** `xr_get_game_resolution()` (`xr_session.c`), which
|
||||
`xr_input.c` uses to convert a game-quad ray hit's normalized u,v into
|
||||
the pixel coordinates it feeds the engine via `xr_mouse_move()`, returned
|
||||
the game quad's OpenXR swapchain's fixed pixel size
|
||||
(`g_game_swapchain.width/height`) - set once at launch and never resized.
|
||||
`android_draw_surface_as_quad()` (`OpenGL.cc`,
|
||||
`11-android-openxr-present.patch`) always stretch-blits `drawSurface` to
|
||||
fill that swapchain image via a single full-quad draw, regardless of
|
||||
`drawSurface`'s own pixel size, so the swapchain's fixed size has no
|
||||
actual relationship to the coordinate space `MouseX`/`MouseY` live in -
|
||||
that's `drawSurface`'s own current size (`gScreenWide`/`gScreenHigh`),
|
||||
which `ChangeScreenSize()` can change at runtime independently of the
|
||||
swapchain. Using the stale swapchain size there mapped every ray hit
|
||||
into the wrong coordinate space, moving the mouse position out of the
|
||||
engine's actual screen bounds.
|
||||
|
||||
**Fix:** `xr_get_game_resolution()` now returns `gScreenWide`/
|
||||
`gScreenHigh` instead. `xr_get_game_quad_extent()` (the quad's shape and
|
||||
distance in the VR world) is intentionally left as-is, still tied to the
|
||||
swapchain's fixed size - that's a real, unchanging property of the
|
||||
swapchain image itself, unrelated to this bug.
|
||||
|
||||
#### 5.5.8. Persistent `OpenGL error: 1282` logcat spam during 3D gameplay
|
||||
|
||||
Every logcat capture during actual 3D gameplay showed a continuous
|
||||
stream of `OpenGL error: 1282` (roughly twice per frame) from the one
|
||||
pre-existing `glGetError()` check in `opengl_swap_and_restore()`, with
|
||||
no visible effect on rendering (textures, resolution changes, and the
|
||||
cursor all worked correctly per sections 5.5.5-5.5.7's fixes). 1282 is
|
||||
`GL_INVALID_OPERATION` (0x0502) - not `GL_INVALID_ENUM` (0x0500/1280),
|
||||
which earlier debugging had mistakenly assumed given the visual
|
||||
symptoms resembled the immediate-mode bug in 5.5.5.
|
||||
|
||||
Two real, unrelated bugs turned out to be involved, found by adding a
|
||||
temporary chain of `glGetError()` checkpoints across the whole
|
||||
per-frame render path (`mainloop()` → `render_run()` → `fr_rend()` →
|
||||
`fr_prepare_view()`/`fr_start_view()`/`fr_send_view()`) and bisecting
|
||||
which ones read clean:
|
||||
|
||||
**xrPollEvent() leaves a stray error.** `xr_poll_events()`
|
||||
(`xr_session.c`) left gl4es reporting `GL_INVALID_OPERATION` on every
|
||||
call while the session is running, regardless of what this app's own
|
||||
code does. Fixed by draining it (`while (glGetError() != GL_NO_ERROR)`)
|
||||
right after the polling loop.
|
||||
|
||||
**A hack-camera render re-entered the OpenXR frame setup.**
|
||||
`render_hack_cameras()` (`render.c`) calls `fr_rend()` - and so
|
||||
`fr_start_view()`/`fr_send_view()` - a second time per real frame for
|
||||
each active security-camera monitor, rendering into a private software
|
||||
(`FR_DOUBLEB_MASK`) bitmap rather than the actual GL-backed screen.
|
||||
`use_opengl()` already excludes hack-camera/pickup-mode views from the
|
||||
GL texture-mapping functions (`FR_PICKUPM_MASK|FR_HACKCAM_MASK`), but
|
||||
`should_opengl_swap()` - the separate check gating
|
||||
`opengl_start_frame()`/`opengl_end_frame()` in those two functions -
|
||||
didn't, so those ran again mid-frame for a render that was never
|
||||
GL-backed. `19-android-fix-hackcam-opengl-frame.patch` adds the same
|
||||
exclusion to both call sites. This is a genuine bug fixed on its own
|
||||
merits, but bisecting further showed it wasn't actually the source of
|
||||
the 1282 spam - removing it just relocated the checkpoint that caught
|
||||
the error, without changing whether it fired.
|
||||
|
||||
**Actual root cause: binding "framebuffer 0" on Android has no valid
|
||||
target.** `opengl_end_frame()`'s `BindFrameBuffer(nullptr)` binds
|
||||
framebuffer/renderbuffer id 0 to mean "the real window surface" - true
|
||||
on desktop, where that's SDL's actual GL window. On Android, this app's
|
||||
real presentation target is never that window's own EGL surface at
|
||||
all - it's the OpenXR swapchain's per-image FBOs
|
||||
(`xr_swapchain_create()`/`xr_swapchain_acquire()`, `xr_swapchain.c`).
|
||||
Whatever framebuffer/renderbuffer 0 actually resolves to for gl4es in
|
||||
this configuration, binding it reliably set a persistent
|
||||
`GL_INVALID_OPERATION` - confirmed by bisecting through the *entire*
|
||||
render path with per-call checkpoints, all clean right up until the one
|
||||
placed immediately after this exact call. The bind is also functionally
|
||||
dead regardless of the error: by the time anything next draws, Shock.c's
|
||||
`SDLDraw()` has already run `xr_frame_begin()` →
|
||||
`xr_swapchain_acquire()`, which rebinds to that frame's real swapchain
|
||||
FBO before `opengl_swap_and_restore()` (the only thing that draws before
|
||||
the next `opengl_start_frame()`'s own `BindFrameBuffer(&backupBuffer)`)
|
||||
runs - so nothing ever actually renders while framebuffer 0 is bound.
|
||||
`20-android-no-default-framebuffer.patch` skips the call on Android
|
||||
entirely. Confirmed via logcat: this fully eliminated the error across
|
||||
an extended play session with 3D rendering active throughout, with no
|
||||
change in what actually renders.
|
||||
|
||||
An earlier theory (that the OpenXR runtime's own GL work inside
|
||||
`xrAcquireSwapchainImage`/`xrWaitSwapchainImage`/`xrEndFrame` was
|
||||
leaving stray driver-level errors on the shared EGL context) prompted a
|
||||
dlsym'd, gl4es-bypassing `glGetError()` drain at those call sites. Once
|
||||
the above was fixed, testing showed that drain was never actually
|
||||
necessary, so it was removed rather than kept as unproven defensive
|
||||
code.
|
||||
|
||||
## 6. License
|
||||
|
||||
The original tooling in this repository (the Docker build image, build
|
||||
|
||||
@@ -29,9 +29,33 @@ if (prebuiltDir == null) {
|
||||
}
|
||||
|
||||
// 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'
|
||||
// desktop tarball's versioning) via -PquestshockVersionName/Code. Building
|
||||
// directly from Android Studio (the normal day-to-day workflow - see
|
||||
// README) never passes that property, so the default below has to carry
|
||||
// its own weight rather than just being a fallback for some rare, other,
|
||||
// unsupported path: it runs `git describe` itself, the same way
|
||||
// engine/CMakeLists.txt's own PROJECT_REVERSION_STRING does for the
|
||||
// engine's *own* separate version string - "vX.Y.Z" on a clean tagged
|
||||
// commit, or "vX.Y.Z-N-gHASH[-dirty]" otherwise, so a build between
|
||||
// releases is still identifiable from the in-headset menu (see
|
||||
// MenuOverlay.java's header, which reads BuildConfig.VERSION_NAME).
|
||||
def gitDescribeVersionName = {
|
||||
try {
|
||||
def proc = ["git", "-C", projectDir.toString(), "describe", "--tags", "--always", "--dirty"].execute()
|
||||
proc.waitFor()
|
||||
if (proc.exitValue() == 0) {
|
||||
def out = proc.text.trim()
|
||||
if (out) {
|
||||
return out
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// git not on PATH, or this isn't a git checkout at all (e.g. a
|
||||
// source tarball) - fall through to the placeholder below.
|
||||
}
|
||||
return "0.0.0-dev"
|
||||
}
|
||||
def questshockVersionName = project.hasProperty('questshockVersionName') ? project.property('questshockVersionName') : gitDescribeVersionName()
|
||||
def questshockVersionCode = project.hasProperty('questshockVersionCode') ? project.property('questshockVersionCode').toInteger() : 1
|
||||
|
||||
// Auto-refreshes the staged, patched engine/ and gl4es-src/ copies and
|
||||
@@ -171,7 +195,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_menu.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;${projectDir}/src/main/cpp/qs_config.c"
|
||||
abiFilters 'arm64-v8a'
|
||||
}
|
||||
}
|
||||
@@ -189,6 +213,13 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// Generates BuildConfig (specifically BuildConfig.VERSION_NAME, read by
|
||||
// MenuOverlay.java's header) - off by default since AGP 8.0, so this
|
||||
// has to be requested explicitly.
|
||||
buildFeatures {
|
||||
buildConfig true
|
||||
}
|
||||
|
||||
lint {
|
||||
abortOnError false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,986 @@
|
||||
// Persistent, human-editable JSON config file - see qs_config.h and
|
||||
// CONFIG.md (repo root) for the documented schema and behavior. This file
|
||||
// deliberately touches engine internals directly (gShockPrefs, mode_id,
|
||||
// change_svga_screen_mode(), message_info()) - unlike xr_session.c/
|
||||
// xr_input.c/xr_swapchain.c, which stay purely XR/SDL-facing and never
|
||||
// reach into engine state this way. It's still compiled into the same
|
||||
// target as the rest of the engine (see android/app/build.gradle's
|
||||
// ANDROID_EXTRA_SOURCES, same as those files), so engine headers resolve
|
||||
// via the same include_directories() every engine .c file already uses.
|
||||
|
||||
#include <ctype.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include <SDL.h>
|
||||
#include <jni.h>
|
||||
|
||||
#include "qs_config.h"
|
||||
|
||||
#include "xr_input.h"
|
||||
#include "xr_session.h"
|
||||
|
||||
#include "Prefs.h"
|
||||
#include "fullscrn.h"
|
||||
#include "lg.h"
|
||||
#include "tools.h"
|
||||
|
||||
#define QS_CONFIG_FILENAME "questshock.config.json"
|
||||
|
||||
// ===========================================================================
|
||||
// Minimal JSON parser
|
||||
//
|
||||
// Just enough to read this file's own schema (nested one level: group ->
|
||||
// string-valued option) while safely skipping over anything it doesn't
|
||||
// recognize - unknown keys, wrong-typed values, arrays, nested objects - so
|
||||
// a hand-edited file with extra/future content never breaks parsing of the
|
||||
// parts this version does care about. No third-party JSON library exists
|
||||
// anywhere in this repo; this matches the project's own existing style of a
|
||||
// small hand-rolled parser for its persisted files (see Prefs.c's prefs.txt/
|
||||
// keybinds.txt parsing).
|
||||
//
|
||||
// `error` is sticky: once any function below sets it, every other parser
|
||||
// function becomes a no-op returning false, so a syntax error anywhere
|
||||
// cleanly aborts the whole parse rather than continuing from a
|
||||
// cursor position left in an indeterminate spot by the failure.
|
||||
// ===========================================================================
|
||||
|
||||
typedef struct {
|
||||
const char *p;
|
||||
const char *end;
|
||||
bool error;
|
||||
} QsJsonParser;
|
||||
|
||||
static void qs_json_fail(QsJsonParser *p) { p->error = true; }
|
||||
|
||||
static void qs_json_skip_ws(QsJsonParser *p) {
|
||||
while (!p->error && p->p < p->end && isspace((unsigned char)*p->p))
|
||||
p->p++;
|
||||
}
|
||||
|
||||
static bool qs_json_expect(QsJsonParser *p, char c) {
|
||||
if (p->error || p->p >= p->end || *p->p != c) {
|
||||
qs_json_fail(p);
|
||||
return false;
|
||||
}
|
||||
p->p++;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Parses (and unescapes \" \\ \/ \n \t \r \b \f) a JSON string literal at
|
||||
// the cursor into out, NUL-terminated, truncated (not treated as an error)
|
||||
// if longer than out_size - 1 bytes. out may be NULL (e.g. when skipping a
|
||||
// string value we don't care about) - the bytes are simply discarded.
|
||||
// \uXXXX escapes aren't supported (not needed for anything this file writes
|
||||
// or a realistic hand edit) and fail the parse like any other malformed
|
||||
// input.
|
||||
static bool qs_json_parse_string(QsJsonParser *p, char *out, size_t out_size) {
|
||||
if (p->error || !qs_json_expect(p, '"'))
|
||||
return false;
|
||||
size_t n = 0;
|
||||
while (p->p < p->end && *p->p != '"') {
|
||||
char c = *p->p;
|
||||
if (c == '\\') {
|
||||
p->p++;
|
||||
if (p->p >= p->end) {
|
||||
qs_json_fail(p);
|
||||
return false;
|
||||
}
|
||||
switch (*p->p) {
|
||||
case '"':
|
||||
c = '"';
|
||||
break;
|
||||
case '\\':
|
||||
c = '\\';
|
||||
break;
|
||||
case '/':
|
||||
c = '/';
|
||||
break;
|
||||
case 'n':
|
||||
c = '\n';
|
||||
break;
|
||||
case 't':
|
||||
c = '\t';
|
||||
break;
|
||||
case 'r':
|
||||
c = '\r';
|
||||
break;
|
||||
case 'b':
|
||||
c = '\b';
|
||||
break;
|
||||
case 'f':
|
||||
c = '\f';
|
||||
break;
|
||||
default:
|
||||
qs_json_fail(p);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (out && n + 1 < out_size)
|
||||
out[n++] = c;
|
||||
p->p++;
|
||||
}
|
||||
if (p->p >= p->end) {
|
||||
qs_json_fail(p);
|
||||
return false;
|
||||
}
|
||||
p->p++; // closing quote
|
||||
if (out)
|
||||
out[n] = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool qs_json_skip_number(QsJsonParser *p) {
|
||||
const char *start = p->p;
|
||||
if (p->p < p->end && *p->p == '-')
|
||||
p->p++;
|
||||
while (p->p < p->end && (isdigit((unsigned char)*p->p) || *p->p == '.' || *p->p == 'e' ||
|
||||
*p->p == 'E' || *p->p == '+' || *p->p == '-'))
|
||||
p->p++;
|
||||
if (p->p == start) {
|
||||
qs_json_fail(p);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Parses a JSON number at the cursor into *out - unlike qs_json_skip_number()
|
||||
// above (which every other option so far has only ever needed, since every
|
||||
// existing option is a string enum), this one actually extracts the value.
|
||||
// Same scan-then-copy-then-parse approach as qs_json_parse_string(), needed
|
||||
// because the source buffer isn't NUL-terminated at an arbitrary offset, so
|
||||
// strtof() can't just be pointed at the cursor directly.
|
||||
static bool qs_json_parse_number(QsJsonParser *p, float *out) {
|
||||
if (p->error)
|
||||
return false;
|
||||
const char *start = p->p;
|
||||
if (p->p < p->end && *p->p == '-')
|
||||
p->p++;
|
||||
while (p->p < p->end && (isdigit((unsigned char)*p->p) || *p->p == '.' || *p->p == 'e' ||
|
||||
*p->p == 'E' || *p->p == '+' || *p->p == '-'))
|
||||
p->p++;
|
||||
size_t len = (size_t)(p->p - start);
|
||||
if (len == 0 || len >= 64) {
|
||||
qs_json_fail(p);
|
||||
return false;
|
||||
}
|
||||
char buf[64];
|
||||
memcpy(buf, start, len);
|
||||
buf[len] = '\0';
|
||||
char *endptr = NULL;
|
||||
*out = strtof(buf, &endptr);
|
||||
if (endptr == buf) {
|
||||
qs_json_fail(p);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool qs_json_skip_literal(QsJsonParser *p, const char *literal) {
|
||||
size_t len = strlen(literal);
|
||||
if ((size_t)(p->end - p->p) < len || strncmp(p->p, literal, len) != 0) {
|
||||
qs_json_fail(p);
|
||||
return false;
|
||||
}
|
||||
p->p += len;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool qs_json_skip_value(QsJsonParser *p);
|
||||
|
||||
typedef void (*QsJsonFieldCallback)(const char *key, QsJsonParser *p, void *ctx);
|
||||
|
||||
// Parses a JSON object (cursor at '{'). For each "key": value pair, calls
|
||||
// field_cb with the cursor positioned right after the colon - field_cb must
|
||||
// fully consume that value itself, one way or another (see
|
||||
// qs_json_take_string_field() below), since this function only ever
|
||||
// advances past the key + colon on its own.
|
||||
static bool qs_json_parse_object(QsJsonParser *p, QsJsonFieldCallback field_cb, void *ctx) {
|
||||
if (p->error || !qs_json_expect(p, '{'))
|
||||
return false;
|
||||
qs_json_skip_ws(p);
|
||||
if (!p->error && p->p < p->end && *p->p == '}') {
|
||||
p->p++;
|
||||
return true;
|
||||
}
|
||||
while (!p->error) {
|
||||
char key[64];
|
||||
if (!qs_json_parse_string(p, key, sizeof(key)))
|
||||
return false;
|
||||
qs_json_skip_ws(p);
|
||||
if (!qs_json_expect(p, ':'))
|
||||
return false;
|
||||
qs_json_skip_ws(p);
|
||||
field_cb(key, p, ctx);
|
||||
qs_json_skip_ws(p);
|
||||
if (p->error || p->p >= p->end) {
|
||||
qs_json_fail(p);
|
||||
return false;
|
||||
}
|
||||
if (*p->p == ',') {
|
||||
p->p++;
|
||||
qs_json_skip_ws(p);
|
||||
continue;
|
||||
}
|
||||
if (*p->p == '}') {
|
||||
p->p++;
|
||||
return true;
|
||||
}
|
||||
qs_json_fail(p);
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool qs_json_skip_array(QsJsonParser *p) {
|
||||
if (p->error || !qs_json_expect(p, '['))
|
||||
return false;
|
||||
qs_json_skip_ws(p);
|
||||
if (!p->error && p->p < p->end && *p->p == ']') {
|
||||
p->p++;
|
||||
return true;
|
||||
}
|
||||
while (!p->error) {
|
||||
if (!qs_json_skip_value(p))
|
||||
return false;
|
||||
qs_json_skip_ws(p);
|
||||
if (p->error || p->p >= p->end) {
|
||||
qs_json_fail(p);
|
||||
return false;
|
||||
}
|
||||
if (*p->p == ',') {
|
||||
p->p++;
|
||||
qs_json_skip_ws(p);
|
||||
continue;
|
||||
}
|
||||
if (*p->p == ']') {
|
||||
p->p++;
|
||||
return true;
|
||||
}
|
||||
qs_json_fail(p);
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// field_cb for qs_json_parse_object() when it's only being used to skip a
|
||||
// nested object we don't recognize - every field's value is itself skipped.
|
||||
static void qs_json_skip_field(const char *key, QsJsonParser *p, void *ctx) {
|
||||
(void)key;
|
||||
(void)ctx;
|
||||
qs_json_skip_value(p);
|
||||
}
|
||||
|
||||
static bool qs_json_skip_value(QsJsonParser *p) {
|
||||
if (p->error)
|
||||
return false;
|
||||
qs_json_skip_ws(p);
|
||||
if (p->p >= p->end) {
|
||||
qs_json_fail(p);
|
||||
return false;
|
||||
}
|
||||
switch (*p->p) {
|
||||
case '"':
|
||||
return qs_json_parse_string(p, NULL, 0);
|
||||
case '{':
|
||||
return qs_json_parse_object(p, qs_json_skip_field, NULL);
|
||||
case '[':
|
||||
return qs_json_skip_array(p);
|
||||
case 't':
|
||||
return qs_json_skip_literal(p, "true");
|
||||
case 'f':
|
||||
return qs_json_skip_literal(p, "false");
|
||||
case 'n':
|
||||
return qs_json_skip_literal(p, "null");
|
||||
default:
|
||||
return qs_json_skip_number(p);
|
||||
}
|
||||
}
|
||||
|
||||
// Tries to parse the value at the cursor as a string into out (truncating
|
||||
// safely if too long); if it isn't a string at all, skips it instead so the
|
||||
// parse can continue past this field regardless of a wrong-typed value in
|
||||
// the file. Either way, fully consumes exactly one value.
|
||||
static void qs_json_take_string_field(QsJsonParser *p, char *out, size_t out_size) {
|
||||
if (!p->error && p->p < p->end && *p->p == '"')
|
||||
qs_json_parse_string(p, out, out_size);
|
||||
else
|
||||
qs_json_skip_value(p);
|
||||
}
|
||||
|
||||
// Same "skip if wrong type" behavior as qs_json_take_string_field() above,
|
||||
// for a numeric field - sets *has to true only on success, since (unlike
|
||||
// the string groups, where an empty string means "not present") 0.0f is a
|
||||
// perfectly valid value and can't double as an absent-sentinel.
|
||||
static void qs_json_take_number_field(QsJsonParser *p, float *out, bool *has) {
|
||||
if (!p->error && p->p < p->end && (isdigit((unsigned char)*p->p) || *p->p == '-')) {
|
||||
if (qs_json_parse_number(p, out))
|
||||
*has = true;
|
||||
} else {
|
||||
qs_json_skip_value(p);
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Schema: "video_options" group
|
||||
// ===========================================================================
|
||||
|
||||
typedef struct {
|
||||
char renderer[16]; // "" = not present in the file
|
||||
char resolution[16]; // "" = not present in the file
|
||||
} QsParsedVideoOptions;
|
||||
|
||||
typedef struct {
|
||||
char language[16]; // "" = not present in the file
|
||||
} QsParsedGeneral;
|
||||
|
||||
typedef struct {
|
||||
float x, z, yaw_degrees;
|
||||
// Unlike every string-valued option above, 0.0f is a perfectly valid
|
||||
// value here and can't double as "not present in the file" - see
|
||||
// qs_json_take_number_field()'s own comment.
|
||||
bool has_x, has_z, has_yaw_degrees;
|
||||
} QsParsedPlayerPosition;
|
||||
|
||||
typedef struct {
|
||||
QsParsedVideoOptions video_options;
|
||||
bool has_video_options;
|
||||
QsParsedGeneral general;
|
||||
bool has_general;
|
||||
QsParsedPlayerPosition player_position;
|
||||
bool has_player_position;
|
||||
} QsParsedConfig;
|
||||
|
||||
static void qs_parse_video_options_field(const char *key, QsJsonParser *p, void *ctx) {
|
||||
QsParsedVideoOptions *out = (QsParsedVideoOptions *)ctx;
|
||||
if (strcmp(key, "renderer") == 0)
|
||||
qs_json_take_string_field(p, out->renderer, sizeof(out->renderer));
|
||||
else if (strcmp(key, "resolution") == 0)
|
||||
qs_json_take_string_field(p, out->resolution, sizeof(out->resolution));
|
||||
else
|
||||
qs_json_skip_value(p);
|
||||
}
|
||||
|
||||
static void qs_parse_general_field(const char *key, QsJsonParser *p, void *ctx) {
|
||||
QsParsedGeneral *out = (QsParsedGeneral *)ctx;
|
||||
if (strcmp(key, "language") == 0)
|
||||
qs_json_take_string_field(p, out->language, sizeof(out->language));
|
||||
else
|
||||
qs_json_skip_value(p);
|
||||
}
|
||||
|
||||
static void qs_parse_player_position_field(const char *key, QsJsonParser *p, void *ctx) {
|
||||
QsParsedPlayerPosition *out = (QsParsedPlayerPosition *)ctx;
|
||||
if (strcmp(key, "x") == 0)
|
||||
qs_json_take_number_field(p, &out->x, &out->has_x);
|
||||
else if (strcmp(key, "z") == 0)
|
||||
qs_json_take_number_field(p, &out->z, &out->has_z);
|
||||
else if (strcmp(key, "yaw_degrees") == 0)
|
||||
qs_json_take_number_field(p, &out->yaw_degrees, &out->has_yaw_degrees);
|
||||
else
|
||||
qs_json_skip_value(p);
|
||||
}
|
||||
|
||||
static void qs_parse_top_level_field(const char *key, QsJsonParser *p, void *ctx) {
|
||||
QsParsedConfig *out = (QsParsedConfig *)ctx;
|
||||
if (strcmp(key, "video_options") == 0 && !p->error && p->p < p->end && *p->p == '{')
|
||||
out->has_video_options = qs_json_parse_object(p, qs_parse_video_options_field, &out->video_options);
|
||||
else if (strcmp(key, "general") == 0 && !p->error && p->p < p->end && *p->p == '{')
|
||||
out->has_general = qs_json_parse_object(p, qs_parse_general_field, &out->general);
|
||||
else if (strcmp(key, "player_position") == 0 && !p->error && p->p < p->end && *p->p == '{')
|
||||
out->has_player_position =
|
||||
qs_json_parse_object(p, qs_parse_player_position_field, &out->player_position);
|
||||
else
|
||||
qs_json_skip_value(p);
|
||||
}
|
||||
|
||||
// Reads and parses QS_CONFIG_FILENAME into *out. Returns false (with *out
|
||||
// zeroed) if the file doesn't exist, is empty, or fails to parse as valid
|
||||
// JSON - logging a WARN only for the last case, since a missing file is the
|
||||
// normal/expected state, not an error.
|
||||
static bool qs_config_read_file(QsParsedConfig *out) {
|
||||
memset(out, 0, sizeof(*out));
|
||||
FILE *f = fopen(QS_CONFIG_FILENAME, "rb");
|
||||
if (!f)
|
||||
return false;
|
||||
fseek(f, 0, SEEK_END);
|
||||
long size = ftell(f);
|
||||
fseek(f, 0, SEEK_SET);
|
||||
if (size <= 0) {
|
||||
fclose(f);
|
||||
return false;
|
||||
}
|
||||
char *buf = (char *)malloc((size_t)size);
|
||||
if (!buf) {
|
||||
fclose(f);
|
||||
return false;
|
||||
}
|
||||
size_t got = fread(buf, 1, (size_t)size, f);
|
||||
fclose(f);
|
||||
|
||||
QsJsonParser parser = {buf, buf + got, false};
|
||||
qs_json_skip_ws(&parser);
|
||||
bool ok = qs_json_parse_object(&parser, qs_parse_top_level_field, out) && !parser.error;
|
||||
free(buf);
|
||||
if (!ok) {
|
||||
WARN("%s: malformed JSON - ignoring file", QS_CONFIG_FILENAME);
|
||||
memset(out, 0, sizeof(*out));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Video Options: renderer
|
||||
// ===========================================================================
|
||||
|
||||
// Already proven live-safe by the existing Ctrl+G renderer-toggle hotkey
|
||||
// (toggle_opengl(), OpenGL.cc), which does nothing more than flip this same
|
||||
// field - every render-mode check elsewhere in the engine reads
|
||||
// gShockPrefs.doUseOpenGL directly, every frame, so no extra "apply" step is
|
||||
// needed beyond setting it.
|
||||
#define QS_RENDERER_REQUIRES_RESTART false
|
||||
|
||||
static bool qs_apply_renderer(const char *value, bool live) {
|
||||
(void)live;
|
||||
bool want_opengl;
|
||||
if (strcmp(value, "opengl") == 0) {
|
||||
want_opengl = true;
|
||||
} else if (strcmp(value, "software") == 0) {
|
||||
want_opengl = false;
|
||||
} else {
|
||||
WARN("%s: video_options.renderer=\"%s\" is invalid (expected \"software\" or \"opengl\") - ignoring",
|
||||
QS_CONFIG_FILENAME, value);
|
||||
return false;
|
||||
}
|
||||
bool changed = gShockPrefs.doUseOpenGL != want_opengl;
|
||||
gShockPrefs.doUseOpenGL = want_opengl;
|
||||
return changed;
|
||||
}
|
||||
|
||||
static const char *qs_current_renderer(void) { return gShockPrefs.doUseOpenGL ? "opengl" : "software"; }
|
||||
|
||||
// ===========================================================================
|
||||
// Video Options: resolution
|
||||
// ===========================================================================
|
||||
|
||||
// Live-appliable: change_svga_screen_mode() (below) handles a real mode_id
|
||||
// change fully, including reallocating the screen buffer - see
|
||||
// qs_apply_resolution()'s own comment for the one extra step that takes
|
||||
// (screen_draw()) that a naive call site doesn't get for free.
|
||||
#define QS_RESOLUTION_REQUIRES_RESTART false
|
||||
|
||||
// mode_id (fullscrn.c) is the index change_svga_screen_mode() actually
|
||||
// reads - matches the same field Prefs.c's own SavePrefs()/SetShockGlobals()
|
||||
// read/write for gShockPrefs.doVideoMode.
|
||||
extern short mode_id;
|
||||
|
||||
// screen.c - declared locally (game_screen.h's other declarations pull in
|
||||
// UI-region types this file has no other reason to need) rather than
|
||||
// #include "game_screen.h", matching how mode_id above is declared.
|
||||
extern errtype screen_draw(void);
|
||||
|
||||
static const struct {
|
||||
const char *name;
|
||||
short mode;
|
||||
} kResolutions[] = {
|
||||
{"320x200", 0}, {"320x400", 1}, {"640x400", 2}, {"640x480", 3}, {"1024x768", 4},
|
||||
};
|
||||
#define QS_NUM_RESOLUTIONS (sizeof(kResolutions) / sizeof(kResolutions[0]))
|
||||
|
||||
static bool qs_apply_resolution(const char *value, bool live) {
|
||||
for (size_t i = 0; i < QS_NUM_RESOLUTIONS; i++) {
|
||||
if (strcmp(value, kResolutions[i].name) != 0)
|
||||
continue;
|
||||
bool changed = mode_id != kResolutions[i].mode;
|
||||
gShockPrefs.doVideoMode = kResolutions[i].mode;
|
||||
mode_id = kResolutions[i].mode;
|
||||
if (changed && live) {
|
||||
change_svga_screen_mode();
|
||||
// change_svga_screen_mode() only reallocates the screen buffer
|
||||
// (gr_alloc_screen()/gr_set_screen(), fullscrn.c) when mode_id
|
||||
// actually differs from its own last-applied value
|
||||
// (convert_use_mode) - true here, since changed is set above.
|
||||
// screen.c's screen_start() (the normal "enter the 3D game
|
||||
// screen" path, and the only other caller that hits this same
|
||||
// branch) always calls screen_draw() immediately afterward to
|
||||
// repaint the background/MFD/side-icon/inventory/status chrome
|
||||
// onto whatever buffer is now current; nothing else does, so a
|
||||
// mid-session mode-index change like this one otherwise leaves
|
||||
// that chrome blank (all still functional/redrawing correctly
|
||||
// frame to frame - automap content, inventory grid values,
|
||||
// etc. - just missing the static art around them) until the
|
||||
// next full mode transition. screen_draw() itself doesn't
|
||||
// assume any particular UI mode is active (automap/inventory/
|
||||
// main view), so it's safe to call regardless of which one the
|
||||
// resolution change happened during.
|
||||
screen_draw();
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
WARN("%s: video_options.resolution=\"%s\" is invalid - ignoring", QS_CONFIG_FILENAME, value);
|
||||
return false;
|
||||
}
|
||||
|
||||
static const char *qs_current_resolution(void) {
|
||||
for (size_t i = 0; i < QS_NUM_RESOLUTIONS; i++)
|
||||
if (kResolutions[i].mode == mode_id)
|
||||
return kResolutions[i].name;
|
||||
return "640x480"; // shouldn't happen - safe fallback for Save
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// General: language
|
||||
// ===========================================================================
|
||||
|
||||
// Live-appliable: language_change() (below) is the resource-reload half of
|
||||
// what the game's own options menu calls when the player changes language
|
||||
// there (see wrapper.c's language_dealfunc(), wired up via
|
||||
// multi_init(..., REF_STR_Language, ...)) - closes/reopens the
|
||||
// language-specific resource files, updates which_lang/QUESTVAR, and
|
||||
// refreshes the inventory/MFD/side-icon panels' own language-dependent
|
||||
// text/art. Deliberately NOT calling language_dealfunc() itself: its extra
|
||||
// render_run()/opanel_redraw() repaint step assumes the game's own
|
||||
// in-game options-panel widget is the thing currently on screen, which
|
||||
// isn't true when this is triggered from our independent VR overlay menu -
|
||||
// confirmed by a device crash (SIGSEGV, null pointer) inside render_run()'s
|
||||
// 3D-view frustum-clipping code when called this way. The game's normal
|
||||
// per-frame render loop already redraws everything anyway.
|
||||
#define QS_LANGUAGE_REQUIRES_RESTART false
|
||||
|
||||
// wrapper.h declares this one, but including the whole header pulls in
|
||||
// other prototypes referencing types this file has no other reason to need
|
||||
// - same reasoning as screen_draw() above.
|
||||
extern void language_change(uchar lang);
|
||||
|
||||
// gamestrn.c/init.c - declared locally rather than pulling in their
|
||||
// headers, same reasoning as language_change() above. language_change()
|
||||
// itself indexes these two by lang (0/1/2) to reopen the string/MFD-art
|
||||
// resource files for the new language - but it does so destructively,
|
||||
// closing the *current* (working) files first, with no fallback if the
|
||||
// reopen then fails. qs_language_resources_available() below checks both
|
||||
// files exist *before* ever calling language_change(), so a language whose
|
||||
// resource pack wasn't fully installed can't leave the game with no valid
|
||||
// resource file open at all.
|
||||
extern char *language_files[];
|
||||
extern char *mfdart_files[];
|
||||
|
||||
static const struct {
|
||||
const char *name;
|
||||
short language;
|
||||
} kLanguages[] = {
|
||||
{"english", 0},
|
||||
{"french", 1},
|
||||
{"german", 2},
|
||||
};
|
||||
#define QS_NUM_LANGUAGES (sizeof(kLanguages) / sizeof(kLanguages[0]))
|
||||
|
||||
// Confirmed necessary on a real device: this project's own Android data set
|
||||
// ships frnstrng.res/gerstrng.res (language_files[]) but not
|
||||
// mfdfrn.res/mfdger.res (mfdart_files[]) - selecting French or German used
|
||||
// to call language_change(), which closes the working English resource
|
||||
// files before discovering the new ones don't exist, leaving no valid
|
||||
// resource file open at all and crashing (SIGBUS, null resource pointer)
|
||||
// the next time any resource lookup happened.
|
||||
static bool qs_language_resources_available(short lang) {
|
||||
FILE *f = fopen(language_files[lang], "rb");
|
||||
if (!f)
|
||||
return false;
|
||||
fclose(f);
|
||||
f = fopen(mfdart_files[lang], "rb");
|
||||
if (!f)
|
||||
return false;
|
||||
fclose(f);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Defined further down, alongside qs_show_restart_dialog() (same
|
||||
// find-class-via-classloader JNI plumbing) - forward-declared here since
|
||||
// qs_apply_language() above is defined earlier in the file.
|
||||
static void qs_show_message_dialog(const char *message);
|
||||
|
||||
static bool qs_apply_language(const char *value, bool live) {
|
||||
for (size_t i = 0; i < QS_NUM_LANGUAGES; i++) {
|
||||
if (strcmp(value, kLanguages[i].name) != 0)
|
||||
continue;
|
||||
if (!qs_language_resources_available(kLanguages[i].language)) {
|
||||
WARN("%s: general.language=\"%s\" - resource files for this language aren't installed "
|
||||
"- not switching",
|
||||
QS_CONFIG_FILENAME, value);
|
||||
// Only when there's a menu to show it in - live=false is the
|
||||
// startup path (qs_config_load_startup()), before the menu
|
||||
// overlay exists; qs_show_message_dialog() would just silently
|
||||
// no-op there anyway (MenuOverlay.instance is still null), but
|
||||
// there's no point trying.
|
||||
if (live) {
|
||||
char message[256];
|
||||
snprintf(message, sizeof(message),
|
||||
"Can't switch to %s: its resource files (%s, %s) aren't installed in "
|
||||
"res/data/ - see CONFIG.md.",
|
||||
value, language_files[kLanguages[i].language],
|
||||
mfdart_files[kLanguages[i].language]);
|
||||
qs_show_message_dialog(message);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
bool changed = gShockPrefs.goLanguage != kLanguages[i].language;
|
||||
gShockPrefs.goLanguage = kLanguages[i].language;
|
||||
if (changed && live)
|
||||
language_change((uchar)kLanguages[i].language);
|
||||
return changed;
|
||||
}
|
||||
WARN("%s: general.language=\"%s\" is invalid - ignoring", QS_CONFIG_FILENAME, value);
|
||||
return false;
|
||||
}
|
||||
|
||||
static const char *qs_current_language_name(void) {
|
||||
for (size_t i = 0; i < QS_NUM_LANGUAGES; i++)
|
||||
if (kLanguages[i].language == gShockPrefs.goLanguage)
|
||||
return kLanguages[i].name;
|
||||
return "english"; // shouldn't happen - safe fallback for Save
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// General: player position
|
||||
// ===========================================================================
|
||||
|
||||
// Live-appliable: xr_session_set_player_transform() (xr_session.c) is a
|
||||
// plain state setter with no XR API calls of its own - safe to call before
|
||||
// xr_init() has even run (which is how startup seeding works here, unlike
|
||||
// every other option: nothing in xr_session.c exists yet to "apply" this
|
||||
// to at startup, so the value is simply stored and picked up once
|
||||
// xr_init() creates its own play space - see that function's own comment)
|
||||
// as well as live, mid-session.
|
||||
#define QS_PLAYER_POSITION_REQUIRES_RESTART false
|
||||
|
||||
// Applies only the fields actually present in *parsed, leaving the other
|
||||
// axis/yaw untouched - e.g. a hand-edited file with just `"yaw_degrees"`
|
||||
// shouldn't reset x/z to 0. Always live-appliable (see above) - the `live`
|
||||
// parameter argument that qs_apply_renderer()/qs_apply_resolution()/
|
||||
// qs_apply_language() take doesn't apply here, so unlike those this takes
|
||||
// no such argument.
|
||||
static bool qs_apply_player_position(const QsParsedPlayerPosition *parsed) {
|
||||
float x, z, yaw_degrees;
|
||||
xr_session_get_player_transform(&x, &z, &yaw_degrees);
|
||||
bool changed = false;
|
||||
if (parsed->has_x && parsed->x != x) {
|
||||
x = parsed->x;
|
||||
changed = true;
|
||||
}
|
||||
if (parsed->has_z && parsed->z != z) {
|
||||
z = parsed->z;
|
||||
changed = true;
|
||||
}
|
||||
if (parsed->has_yaw_degrees && parsed->yaw_degrees != yaw_degrees) {
|
||||
yaw_degrees = parsed->yaw_degrees;
|
||||
changed = true;
|
||||
}
|
||||
if (changed)
|
||||
xr_session_set_player_transform(x, z, yaw_degrees);
|
||||
return changed;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Read-only queries for the Video Options tab (see qs_config.h)
|
||||
// ===========================================================================
|
||||
|
||||
bool qs_config_current_renderer_is_opengl(void) { return gShockPrefs.doUseOpenGL; }
|
||||
|
||||
int qs_config_current_resolution_index(void) {
|
||||
for (size_t i = 0; i < QS_NUM_RESOLUTIONS; i++)
|
||||
if (kResolutions[i].mode == mode_id)
|
||||
return (int)i;
|
||||
return 3; // shouldn't happen - 640x480, matches qs_current_resolution()'s own fallback
|
||||
}
|
||||
|
||||
int qs_config_current_language(void) { return gShockPrefs.goLanguage; }
|
||||
|
||||
// ===========================================================================
|
||||
// Apply / Save orchestration
|
||||
// ===========================================================================
|
||||
|
||||
static void qs_append_restart_reason(char *buf, size_t buf_size, const char *name) {
|
||||
if (!buf || buf_size == 0)
|
||||
return;
|
||||
size_t len = strlen(buf);
|
||||
if (len > 0 && len + 2 < buf_size) {
|
||||
strcat(buf, ", ");
|
||||
len += 2;
|
||||
}
|
||||
if (len < buf_size - 1)
|
||||
strncat(buf, name, buf_size - len - 1);
|
||||
}
|
||||
|
||||
// Applies every option present in *parsed (empty string = not present in
|
||||
// the file - left untouched). live=false is the startup path (no restart
|
||||
// distinction applies that early - see qs_config_load_startup()); live=true
|
||||
// is the mid-session "Load Configuration" button path, where restart_needed
|
||||
// (may be NULL) collects a human-readable, comma-separated list of every
|
||||
// option that both actually changed and is flagged *_REQUIRES_RESTART.
|
||||
static void qs_config_apply_parsed(const QsParsedConfig *parsed, bool live, char *restart_needed,
|
||||
size_t restart_needed_size) {
|
||||
if (restart_needed && restart_needed_size > 0)
|
||||
restart_needed[0] = '\0';
|
||||
if (parsed->has_video_options) {
|
||||
if (parsed->video_options.renderer[0] != '\0') {
|
||||
bool changed = qs_apply_renderer(parsed->video_options.renderer, live);
|
||||
if (changed && QS_RENDERER_REQUIRES_RESTART)
|
||||
qs_append_restart_reason(restart_needed, restart_needed_size, "renderer");
|
||||
}
|
||||
if (parsed->video_options.resolution[0] != '\0') {
|
||||
bool changed = qs_apply_resolution(parsed->video_options.resolution, live);
|
||||
if (changed && QS_RESOLUTION_REQUIRES_RESTART)
|
||||
qs_append_restart_reason(restart_needed, restart_needed_size, "resolution");
|
||||
}
|
||||
}
|
||||
if (parsed->has_general) {
|
||||
if (parsed->general.language[0] != '\0') {
|
||||
bool changed = qs_apply_language(parsed->general.language, live);
|
||||
if (changed && QS_LANGUAGE_REQUIRES_RESTART)
|
||||
qs_append_restart_reason(restart_needed, restart_needed_size, "language");
|
||||
}
|
||||
}
|
||||
if (parsed->has_player_position) {
|
||||
bool changed = qs_apply_player_position(&parsed->player_position);
|
||||
if (changed && QS_PLAYER_POSITION_REQUIRES_RESTART)
|
||||
qs_append_restart_reason(restart_needed, restart_needed_size, "player_position");
|
||||
}
|
||||
}
|
||||
|
||||
static bool qs_config_write_file(void) {
|
||||
FILE *f = fopen(QS_CONFIG_FILENAME, "w");
|
||||
if (!f) {
|
||||
WARN("%s: failed to open for writing", QS_CONFIG_FILENAME);
|
||||
return false;
|
||||
}
|
||||
fprintf(f, "{\n");
|
||||
fprintf(f, " \"video_options\": {\n");
|
||||
fprintf(f, " \"renderer\": \"%s\",\n", qs_current_renderer());
|
||||
fprintf(f, " \"resolution\": \"%s\"\n", qs_current_resolution());
|
||||
fprintf(f, " },\n");
|
||||
fprintf(f, " \"general\": {\n");
|
||||
fprintf(f, " \"language\": \"%s\"\n", qs_current_language_name());
|
||||
fprintf(f, " },\n");
|
||||
{
|
||||
float x, z, yaw_degrees;
|
||||
xr_session_get_player_transform(&x, &z, &yaw_degrees);
|
||||
fprintf(f, " \"player_position\": {\n");
|
||||
fprintf(f, " \"x\": %f,\n", x);
|
||||
fprintf(f, " \"z\": %f,\n", z);
|
||||
fprintf(f, " \"yaw_degrees\": %f\n", yaw_degrees);
|
||||
fprintf(f, " }\n");
|
||||
}
|
||||
fprintf(f, "}\n");
|
||||
fclose(f);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Native -> Java: restart-required dialog
|
||||
// ===========================================================================
|
||||
|
||||
// Mirrors xr_overlay.c's own xr_overlay_find_class() helper (also file-local
|
||||
// there) - FindClass() from this thread can't see app classes on its own
|
||||
// since this thread was attached to the JVM via SDL_AndroidGetJNIEnv()
|
||||
// rather than spawned from Java; the activity's own ClassLoader can.
|
||||
static jclass qs_find_class(JNIEnv *env, jobject activity, const char *name) {
|
||||
jclass activityClass = (*env)->GetObjectClass(env, activity);
|
||||
jmethodID getClassLoader =
|
||||
(*env)->GetMethodID(env, activityClass, "getClassLoader", "()Ljava/lang/ClassLoader;");
|
||||
jobject classLoader = (*env)->CallObjectMethod(env, activity, getClassLoader);
|
||||
jclass classLoaderClass = (*env)->FindClass(env, "java/lang/ClassLoader");
|
||||
jmethodID loadClass =
|
||||
(*env)->GetMethodID(env, classLoaderClass, "loadClass", "(Ljava/lang/String;)Ljava/lang/Class;");
|
||||
jstring className = (*env)->NewStringUTF(env, name);
|
||||
jclass result = (jclass)(*env)->CallObjectMethod(env, classLoader, loadClass, className);
|
||||
if ((*env)->ExceptionCheck(env)) {
|
||||
(*env)->ExceptionClear(env);
|
||||
return NULL;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Shared by qs_show_restart_dialog() and qs_show_message_dialog() below -
|
||||
// both call a MenuOverlay static method taking a single String and
|
||||
// returning void, differing only in which one.
|
||||
static void qs_call_dialog_method(const char *java_method_name, const char *message) {
|
||||
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
|
||||
jobject activity = (jobject)SDL_AndroidGetActivity();
|
||||
if (!env || !activity)
|
||||
return;
|
||||
|
||||
jclass menuOverlayClass = qs_find_class(env, activity, "de.ladkau.questshock.MenuOverlay");
|
||||
if (!menuOverlayClass)
|
||||
return;
|
||||
|
||||
jmethodID method =
|
||||
(*env)->GetStaticMethodID(env, menuOverlayClass, java_method_name, "(Ljava/lang/String;)V");
|
||||
if (!method) {
|
||||
(*env)->ExceptionClear(env);
|
||||
return;
|
||||
}
|
||||
|
||||
jstring messageStr = (*env)->NewStringUTF(env, message);
|
||||
(*env)->CallStaticVoidMethod(env, menuOverlayClass, method, messageStr);
|
||||
if ((*env)->ExceptionCheck(env))
|
||||
(*env)->ExceptionClear(env);
|
||||
}
|
||||
|
||||
static void qs_show_restart_dialog(const char *restart_needed) {
|
||||
char message[256];
|
||||
snprintf(message, sizeof(message),
|
||||
"The following changes require a restart to take effect: %s. Everything else has "
|
||||
"already been applied.",
|
||||
restart_needed);
|
||||
qs_call_dialog_method("showRestartDialog", message);
|
||||
}
|
||||
|
||||
// Same restart-confirmation dialog view (MenuOverlay.java's
|
||||
// showMessageDialog()), minus its Restart Now button - for a plain
|
||||
// informational/warning message with nothing to restart into, currently
|
||||
// only qs_apply_language()'s missing-resource-files warning above.
|
||||
static void qs_show_message_dialog(const char *message) { qs_call_dialog_method("showMessageDialog", message); }
|
||||
|
||||
// ===========================================================================
|
||||
// Public API (see qs_config.h)
|
||||
// ===========================================================================
|
||||
|
||||
static Uint32 s_save_event_type;
|
||||
static Uint32 s_load_event_type;
|
||||
static Uint32 s_set_renderer_event_type;
|
||||
static Uint32 s_set_resolution_event_type;
|
||||
static Uint32 s_set_language_event_type;
|
||||
static Uint32 s_reset_position_event_type;
|
||||
|
||||
void qs_config_load_startup(void) {
|
||||
// Registers the custom SDL event types qs_config_request_save()/_load()/
|
||||
// _set_renderer()/_set_resolution()/_set_language()/_reset_position()
|
||||
// enqueue - done here, once, on the engine's own thread and before the
|
||||
// menu/its buttons could possibly exist yet, rather than lazily from
|
||||
// those functions themselves (which run on Android's UI thread and
|
||||
// could otherwise race each other doing this lazily).
|
||||
Uint32 base = SDL_RegisterEvents(6);
|
||||
s_save_event_type = base;
|
||||
s_load_event_type = base + 1;
|
||||
s_set_renderer_event_type = base + 2;
|
||||
s_set_resolution_event_type = base + 3;
|
||||
s_set_language_event_type = base + 4;
|
||||
s_reset_position_event_type = base + 5;
|
||||
|
||||
QsParsedConfig parsed;
|
||||
if (qs_config_read_file(&parsed))
|
||||
qs_config_apply_parsed(&parsed, /*live=*/false, NULL, 0);
|
||||
|
||||
// A saved player_position only means anything if all three fields are
|
||||
// present (a partial one - e.g. just "yaw_degrees" from a hand-edited
|
||||
// file - isn't a complete, sane transform) - xr_input.c's own
|
||||
// automatic startup recenter (xr_input_recenter_player_to_screen(),
|
||||
// via g_pending_startup_recenter) is the fallback for a missing file,
|
||||
// a missing player_position group, or an incomplete one; skip it here
|
||||
// once a complete saved position was actually loaded, so it doesn't
|
||||
// immediately override what was just applied above.
|
||||
if (parsed.has_player_position && parsed.player_position.has_x &&
|
||||
parsed.player_position.has_z && parsed.player_position.has_yaw_degrees)
|
||||
xr_input_skip_startup_recenter();
|
||||
}
|
||||
|
||||
void qs_config_request_save(void) {
|
||||
SDL_Event event;
|
||||
SDL_zero(event);
|
||||
event.type = s_save_event_type;
|
||||
SDL_PushEvent(&event);
|
||||
}
|
||||
|
||||
void qs_config_request_load(void) {
|
||||
SDL_Event event;
|
||||
SDL_zero(event);
|
||||
event.type = s_load_event_type;
|
||||
SDL_PushEvent(&event);
|
||||
}
|
||||
|
||||
void qs_config_request_set_renderer(bool opengl) {
|
||||
SDL_Event event;
|
||||
SDL_zero(event);
|
||||
event.type = s_set_renderer_event_type;
|
||||
event.user.code = opengl ? 1 : 0;
|
||||
SDL_PushEvent(&event);
|
||||
}
|
||||
|
||||
void qs_config_request_set_resolution(int resolution_index) {
|
||||
SDL_Event event;
|
||||
SDL_zero(event);
|
||||
event.type = s_set_resolution_event_type;
|
||||
event.user.code = resolution_index;
|
||||
SDL_PushEvent(&event);
|
||||
}
|
||||
|
||||
void qs_config_request_set_language(int language_index) {
|
||||
SDL_Event event;
|
||||
SDL_zero(event);
|
||||
event.type = s_set_language_event_type;
|
||||
event.user.code = language_index;
|
||||
SDL_PushEvent(&event);
|
||||
}
|
||||
|
||||
void qs_config_request_reset_position(void) {
|
||||
SDL_Event event;
|
||||
SDL_zero(event);
|
||||
event.type = s_reset_position_event_type;
|
||||
SDL_PushEvent(&event);
|
||||
}
|
||||
|
||||
bool qs_config_handle_event(const SDL_Event *event) {
|
||||
if (event->type == s_save_event_type) {
|
||||
if (qs_config_write_file())
|
||||
message_info("Configuration saved");
|
||||
else
|
||||
message_info("Failed to save configuration");
|
||||
return true;
|
||||
}
|
||||
if (event->type == s_load_event_type) {
|
||||
QsParsedConfig parsed;
|
||||
if (!qs_config_read_file(&parsed)) {
|
||||
message_info("No configuration file to load");
|
||||
return true;
|
||||
}
|
||||
char restart_needed[128];
|
||||
qs_config_apply_parsed(&parsed, /*live=*/true, restart_needed, sizeof(restart_needed));
|
||||
if (restart_needed[0] != '\0')
|
||||
qs_show_restart_dialog(restart_needed);
|
||||
else
|
||||
message_info("Configuration loaded");
|
||||
return true;
|
||||
}
|
||||
if (event->type == s_set_renderer_event_type) {
|
||||
qs_apply_renderer(event->user.code ? "opengl" : "software", /*live=*/true);
|
||||
return true;
|
||||
}
|
||||
if (event->type == s_set_resolution_event_type) {
|
||||
int index = event->user.code;
|
||||
if (index >= 0 && (size_t)index < QS_NUM_RESOLUTIONS)
|
||||
qs_apply_resolution(kResolutions[index].name, /*live=*/true);
|
||||
return true;
|
||||
}
|
||||
if (event->type == s_set_language_event_type) {
|
||||
int index = event->user.code;
|
||||
if (index >= 0 && (size_t)index < QS_NUM_LANGUAGES)
|
||||
qs_apply_language(kLanguages[index].name, /*live=*/true);
|
||||
return true;
|
||||
}
|
||||
if (event->type == s_reset_position_event_type) {
|
||||
// Deliberately not xr_session_set_player_transform(0,0,0) - see
|
||||
// xr_input_recenter_player_to_screen()'s own comment: a "return to
|
||||
// (0,0,0)" reset only undoes thumbstick locomotion, not any real,
|
||||
// physical drift/rotation since the session started, which is what
|
||||
// actually made this feel "slightly off" - recentering onto the
|
||||
// player's current real head pose fixes both at once, and is
|
||||
// unrelated to questshock.config.json (this event doesn't read or
|
||||
// write the config file at all, just like this one never has).
|
||||
xr_input_recenter_player_to_screen();
|
||||
message_info("Recentered");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
#ifndef QUESTSHOCK_QS_CONFIG_H
|
||||
#define QUESTSHOCK_QS_CONFIG_H
|
||||
|
||||
#include <SDL.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
// Persistent, human-editable JSON config file - see CONFIG.md at the repo
|
||||
// root for the documented schema. Lives at a plain relative path
|
||||
// ("questshock.config.json") since the whole engine already runs with its
|
||||
// cwd chdir()'d to the user-facing /sdcard/questshock/ folder (see
|
||||
// QuestShockActivity.java's nativeChdir()) before main() ever runs - the
|
||||
// same way prefs.txt/keybinds.txt (Prefs.c) already do.
|
||||
//
|
||||
// Deliberately independent of prefs.txt: this file is never read or written
|
||||
// as a side effect of prefs.txt load/save, and vice versa.
|
||||
|
||||
// Parses the config file if it exists (silent no-op if it doesn't - this
|
||||
// never creates the file itself) and applies every recognized option
|
||||
// directly into gShockPrefs/mode_id. Call once, early in main() - see
|
||||
// android/engine-patches/21-android-config.patch - after LoadPrefs() (so the
|
||||
// config file can override prefs.txt) and before init_all() (so there's no
|
||||
// live-vs-restart distinction to worry about: nothing is initialized yet, so
|
||||
// every field takes full effect as part of this same startup).
|
||||
void qs_config_load_startup(void);
|
||||
|
||||
// Called from the "Save Configuration"/"Load Configuration" button JNI
|
||||
// handlers (questshock_native.c), which run on Android's UI thread. These
|
||||
// only enqueue a custom SDL event - the actual file I/O and any
|
||||
// gShockPrefs/mode_id/message_info() work happens on the engine's own SDL
|
||||
// main thread instead (see qs_config_handle_event() below), matching this
|
||||
// codebase's existing convention for crossing this exact thread boundary
|
||||
// (xr_session.c's xr_poll_events(), questshock_native.c's SDL_TEXTINPUT
|
||||
// push for keyboard input).
|
||||
void qs_config_request_save(void);
|
||||
void qs_config_request_load(void);
|
||||
|
||||
// Same cross-thread-dispatch reasoning as qs_config_request_save()/_load()
|
||||
// above - called from the Video Options tab's controls (MenuOverlay.java),
|
||||
// which are just as much on Android's UI thread as the Save/Load buttons.
|
||||
// opengl=true/false selects "opengl"/"software"; resolution_index is 0-4,
|
||||
// indexing the same table qs_config_current_resolution_index() below
|
||||
// reads. Applies live immediately (no restart involved - see CONFIG.md);
|
||||
// unlike Load Configuration, these don't post a message_info() HUD
|
||||
// notification for every single tap - the tab's own selection highlight is
|
||||
// the feedback.
|
||||
void qs_config_request_set_renderer(bool opengl);
|
||||
void qs_config_request_set_resolution(int resolution_index);
|
||||
|
||||
// Same cross-thread-dispatch reasoning as the two above - called from the
|
||||
// Main Menu tab's Language row (MenuOverlay.java). language_index is 0
|
||||
// English/1 French/2 German (gShockPrefs.goLanguage's own numbering,
|
||||
// Prefs.h). Applies live immediately, the same way the game's own options
|
||||
// menu applies a language change (see qs_config.c's qs_apply_language()).
|
||||
void qs_config_request_set_language(int language_index);
|
||||
|
||||
// Same cross-thread-dispatch reasoning as the three above - called from the
|
||||
// Main Menu tab's "Reset Position" button. A pure, live "return to a sane
|
||||
// view of the game quad" action (see xr_input.h's own
|
||||
// xr_input_recenter_player_to_screen(), which this dispatches to) -
|
||||
// unrelated to questshock.config.json despite living in this file for the
|
||||
// cross-thread dispatch machinery: it never reads or writes the config
|
||||
// file, and isn't a "reset to (0,0,0)" - it recenters based on the
|
||||
// player's current real head pose instead.
|
||||
void qs_config_request_reset_position(void);
|
||||
|
||||
// Returns true and handles the event if it's one of the six custom events
|
||||
// qs_config_request_save()/_load()/_set_renderer()/_set_resolution()/
|
||||
// _set_language()/_reset_position() enqueue - called from pump_events()
|
||||
// (sdl_events.c, see android/engine-patches/21-android-config.patch) on the
|
||||
// engine's own thread, once per polled event, before its normal switch on
|
||||
// well-known SDL_* event types. Returns false (no-op) for any other event
|
||||
// type, including before the six custom types have even been registered.
|
||||
bool qs_config_handle_event(const SDL_Event *event);
|
||||
|
||||
// Read-only queries for the Video Options tab's own selection highlight
|
||||
// (MenuOverlay.java re-reads these whenever the tab is (re)shown, so it
|
||||
// reflects the live value even if it was last changed some other way, e.g.
|
||||
// the Ctrl+G hotkey or Load Configuration). Safe to call directly from
|
||||
// Android's UI thread despite not going through the event-queue dispatch
|
||||
// the mutating calls above use - same low-risk reasoning already used for
|
||||
// Save Configuration's own reads of gShockPrefs/mode_id: these are simple,
|
||||
// independent POD scalar reads, not a sequence that needs to be atomic
|
||||
// with anything else.
|
||||
bool qs_config_current_renderer_is_opengl(void);
|
||||
int qs_config_current_resolution_index(void);
|
||||
|
||||
// gShockPrefs.goLanguage (0 English/1 French/2 German, Prefs.h) - read by
|
||||
// MenuOverlay.java's/KeyboardOverlay.java's periodic header-refresh timer
|
||||
// to pick which language's chrome text (Strings.java) to display, so a
|
||||
// language change made mid-session via the game's own options menu (or our
|
||||
// own Language row, or Load Configuration) is picked up without needing to
|
||||
// reopen either panel; also doubles as the Main Menu tab's Language row's
|
||||
// own selection highlight, same as qs_config_current_renderer_is_opengl()/
|
||||
// qs_config_current_resolution_index() above. Same off-thread-scalar-read
|
||||
// reasoning as those two getters.
|
||||
int qs_config_current_language(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif // QUESTSHOCK_QS_CONFIG_H
|
||||
@@ -4,12 +4,169 @@
|
||||
// needs, compiled into the same "main" library as engine/ (see
|
||||
// android/engine-patches/01-android-shared-lib.patch's ANDROID_EXTRA_SOURCES)
|
||||
// - engine/ itself is never modified.
|
||||
#include <ctype.h>
|
||||
#include <jni.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <SDL.h>
|
||||
|
||||
#include "qs_config.h"
|
||||
#include "xr_overlay.h"
|
||||
#include "xr_session.h"
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_de_ladkau_questshock_QuestShockActivity_nativeChdir(JNIEnv *env, jclass clazz, jstring path) {
|
||||
const char *cpath = (*env)->GetStringUTFChars(env, path, NULL);
|
||||
chdir(cpath);
|
||||
(*env)->ReleaseStringUTFChars(env, path, cpath);
|
||||
}
|
||||
|
||||
// KeyboardOverlay's hand-built on-screen keyboard (see
|
||||
// KeyboardOverlay.handleCharKeyDown()/handleCharKeyUp()) has no real
|
||||
// Android IME session behind it, so printable characters can't reach the
|
||||
// game via Android's usual IME path - instead this builds and pushes an
|
||||
// SDL_TEXTINPUT event directly, the same event type/shape SDL's own
|
||||
// Android backend pushes for typed text, using only the public
|
||||
// SDL_Event/SDL_PushEvent API. The engine's pump_events() (sdl_events.c)
|
||||
// only picks up printable characters from this event, not from
|
||||
// SDL_KEYDOWN.
|
||||
//
|
||||
// pump_events()'s SDL_TEXTINPUT handling sets sshockKeyStates[code] for
|
||||
// this character and only ever clears it again on a matching SDL_KEYUP.
|
||||
// Any character bound to a continuously-polled motion key (space -> jump,
|
||||
// w -> forward, etc., see MacSrc/Prefs.c's default keybinds.txt) is
|
||||
// polled every physics tick for as long as that state stays set, exactly
|
||||
// like a real held key - so nativeSendPrintableCharDown()/Up() are called
|
||||
// from the key button's own ACTION_DOWN/ACTION_UP (see
|
||||
// KeyboardOverlay.charKeyTouchListener()), not both at once: pushing the
|
||||
// release in the same call as the press would set and clear
|
||||
// sshockKeyStates[code] within a single pump_events() call, before the
|
||||
// game loop's poll (which runs once per frame, before pump_events - see
|
||||
// GameSrc/mainloop.c) ever has a chance to observe the press.
|
||||
JNIEXPORT void JNICALL
|
||||
Java_de_ladkau_questshock_KeyboardOverlay_nativeSendPrintableCharDown(JNIEnv *env, jclass clazz,
|
||||
jchar c) {
|
||||
SDL_Event event;
|
||||
SDL_zero(event);
|
||||
event.type = SDL_TEXTINPUT;
|
||||
event.text.timestamp = SDL_GetTicks();
|
||||
event.text.windowID = 0;
|
||||
event.text.text[0] = (char)c;
|
||||
event.text.text[1] = '\0';
|
||||
SDL_PushEvent(&event);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_de_ladkau_questshock_KeyboardOverlay_nativeSendPrintableCharUp(JNIEnv *env, jclass clazz,
|
||||
jchar c) {
|
||||
SDL_Event releaseEvent;
|
||||
SDL_zero(releaseEvent);
|
||||
releaseEvent.type = SDL_KEYUP;
|
||||
releaseEvent.key.timestamp = SDL_GetTicks();
|
||||
releaseEvent.key.windowID = 0;
|
||||
releaseEvent.key.state = SDL_RELEASED;
|
||||
releaseEvent.key.repeat = 0;
|
||||
// SDL keycodes for printable characters are the character's own
|
||||
// (lowercased) byte value - see sdl_events.c's own comment on this.
|
||||
releaseEvent.key.keysym.sym = (SDL_Keycode)tolower((unsigned char)c);
|
||||
releaseEvent.key.keysym.mod = KMOD_NONE;
|
||||
SDL_PushEvent(&releaseEvent);
|
||||
}
|
||||
|
||||
// KeyboardOverlay's title-bar Close button - the keyboard is a fully
|
||||
// independent overlay quad from the menu launcher (see xr_overlay.h), so
|
||||
// this is the only way to hide it once it's open; the controller's menu
|
||||
// button only ever affects the menu launcher.
|
||||
JNIEXPORT void JNICALL
|
||||
Java_de_ladkau_questshock_KeyboardOverlay_nativeRequestClose(JNIEnv *env, jclass clazz) {
|
||||
xr_overlay_set_visible(xr_session_get_keyboard_overlay(), false);
|
||||
}
|
||||
|
||||
// MenuOverlay's "Keyboard" button - only opens the keyboard overlay, never
|
||||
// touches the menu launcher's own visibility, so both can be shown
|
||||
// together.
|
||||
JNIEXPORT void JNICALL
|
||||
Java_de_ladkau_questshock_MenuOverlay_nativeShowKeyboard(JNIEnv *env, jclass clazz) {
|
||||
xr_overlay_set_visible(xr_session_get_keyboard_overlay(), true);
|
||||
}
|
||||
|
||||
// MenuOverlay's "Save Configuration"/"Load Configuration" buttons - see
|
||||
// qs_config.h for why these only enqueue an event rather than doing the
|
||||
// actual file/engine-state work here directly (this runs on Android's UI
|
||||
// thread, not the engine's own).
|
||||
JNIEXPORT void JNICALL
|
||||
Java_de_ladkau_questshock_MenuOverlay_nativeRequestSaveConfig(JNIEnv *env, jclass clazz) {
|
||||
qs_config_request_save();
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_de_ladkau_questshock_MenuOverlay_nativeRequestLoadConfig(JNIEnv *env, jclass clazz) {
|
||||
qs_config_request_load();
|
||||
}
|
||||
|
||||
// MenuOverlay's Video Options tab - live-appliable controls, see
|
||||
// qs_config.h. Same enqueue-only reasoning as the Save/Load buttons above.
|
||||
JNIEXPORT void JNICALL
|
||||
Java_de_ladkau_questshock_MenuOverlay_nativeRequestSetRenderer(JNIEnv *env, jclass clazz,
|
||||
jboolean opengl) {
|
||||
qs_config_request_set_renderer(opengl);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_de_ladkau_questshock_MenuOverlay_nativeRequestSetResolution(JNIEnv *env, jclass clazz,
|
||||
jint resolutionIndex) {
|
||||
qs_config_request_set_resolution(resolutionIndex);
|
||||
}
|
||||
|
||||
// MenuOverlay's Main Menu tab's Language row - same enqueue-only reasoning as above.
|
||||
JNIEXPORT void JNICALL
|
||||
Java_de_ladkau_questshock_MenuOverlay_nativeRequestSetLanguage(JNIEnv *env, jclass clazz,
|
||||
jint languageIndex) {
|
||||
qs_config_request_set_language(languageIndex);
|
||||
}
|
||||
|
||||
// MenuOverlay's Main Menu tab's "Reset Position" button - same enqueue-only
|
||||
// reasoning as above.
|
||||
JNIEXPORT void JNICALL
|
||||
Java_de_ladkau_questshock_MenuOverlay_nativeRequestResetPosition(JNIEnv *env, jclass clazz) {
|
||||
qs_config_request_reset_position();
|
||||
}
|
||||
|
||||
// Read-only queries - safe to call directly (no enqueue needed, see
|
||||
// qs_config.h's own comment on each of these).
|
||||
JNIEXPORT jboolean JNICALL
|
||||
Java_de_ladkau_questshock_MenuOverlay_nativeGetRendererIsOpenGl(JNIEnv *env, jclass clazz) {
|
||||
return qs_config_current_renderer_is_opengl();
|
||||
}
|
||||
|
||||
JNIEXPORT jint JNICALL
|
||||
Java_de_ladkau_questshock_MenuOverlay_nativeGetResolutionIndex(JNIEnv *env, jclass clazz) {
|
||||
return qs_config_current_resolution_index();
|
||||
}
|
||||
|
||||
JNIEXPORT jint JNICALL
|
||||
Java_de_ladkau_questshock_MenuOverlay_nativeGetLanguage(JNIEnv *env, jclass clazz) {
|
||||
return qs_config_current_language();
|
||||
}
|
||||
|
||||
// MenuOverlay's header - polled on a periodic timer (see its own comment),
|
||||
// not event-driven, so these are plain synchronous reads too.
|
||||
JNIEXPORT jfloat JNICALL
|
||||
Java_de_ladkau_questshock_MenuOverlay_nativeGetFps(JNIEnv *env, jclass clazz) {
|
||||
return xr_session_get_fps();
|
||||
}
|
||||
|
||||
JNIEXPORT jfloat JNICALL
|
||||
Java_de_ladkau_questshock_MenuOverlay_nativeGetFrameTimeMs(JNIEnv *env, jclass clazz) {
|
||||
return xr_session_get_frame_ms();
|
||||
}
|
||||
|
||||
JNIEXPORT jfloat JNICALL
|
||||
Java_de_ladkau_questshock_MenuOverlay_nativeGetCpuUtilization(JNIEnv *env, jclass clazz) {
|
||||
return xr_session_get_cpu_utilization();
|
||||
}
|
||||
|
||||
JNIEXPORT jfloat JNICALL
|
||||
Java_de_ladkau_questshock_MenuOverlay_nativeGetGpuUtilization(JNIEnv *env, jclass clazz) {
|
||||
return xr_session_get_gpu_utilization();
|
||||
}
|
||||
|
||||
@@ -1,15 +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 the menu quad
|
||||
// (xr_menu.c) is hidden, this tests against the game quad xr_session.c
|
||||
// submits and draws a small reticle where each hand's aim ray crosses it;
|
||||
// while the menu is visible, it tests against the menu quad instead and
|
||||
// forwards trigger edges as synthetic touches (no reticle - the menu's
|
||||
// own content comes from MenuOverlay'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
|
||||
@@ -22,22 +37,65 @@ 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);
|
||||
|
||||
// Recenters the player so the game quad ("virtual screen") appears directly
|
||||
// ahead of wherever they're currently really standing/facing - a pure,
|
||||
// live "return to a sane view of the screen" action, unrelated to any
|
||||
// stored/config value (unlike xr_session_set_player_transform()'s other
|
||||
// callers, this never reads or writes questshock.config.json - it's driven
|
||||
// entirely by the player's current real head pose, the same way
|
||||
// xr_input_sync_and_draw()'s own menu re-center is). Called from the Main
|
||||
// Menu tab's "Reset Position" button (via qs_config.c's cross-thread
|
||||
// dispatch - see qs_config_request_reset_position()), but doesn't touch
|
||||
// anything else qs_config.c owns; also called once automatically, from
|
||||
// inside xr_input_sync_and_draw() itself, on whichever frame first has a
|
||||
// valid head pose after startup (see g_pending_startup_recenter's own
|
||||
// comment) - but only as a *fallback* default for when there's no
|
||||
// complete saved player_position to fall back on instead (see
|
||||
// xr_input_skip_startup_recenter() below), since a config-loaded value
|
||||
// describes a transform relative to a *previous* session's real-space
|
||||
// origin, which is only meaningful if that previous position was actually
|
||||
// captured (LOCAL space's own origin isn't a stable point across
|
||||
// launches). Returns false (no-op otherwise) if no valid head pose was
|
||||
// available to recenter onto yet.
|
||||
bool xr_input_recenter_player_to_screen(void);
|
||||
|
||||
// Called once, from qs_config_load_startup() (qs_config.c), when a
|
||||
// complete saved player_position was actually loaded from
|
||||
// questshock.config.json - cancels the pending automatic startup recenter
|
||||
// above so it doesn't immediately override the value that was just
|
||||
// applied. Safe to call regardless of whether xr_input_init() has even run
|
||||
// yet (this only touches the plain file-local flag, no XR calls).
|
||||
void xr_input_skip_startup_recenter(void);
|
||||
|
||||
void xr_input_shutdown(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
|
||||
@@ -1,356 +0,0 @@
|
||||
#include "xr_menu.h"
|
||||
|
||||
#include <dlfcn.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
#include <android/log.h>
|
||||
#include <jni.h>
|
||||
|
||||
#include <EGL/egl.h>
|
||||
#include <GLES3/gl3.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__)
|
||||
|
||||
#define MENU_WIDTH 1024
|
||||
#define MENU_HEIGHT 768
|
||||
|
||||
// Below the game quad (QUAD_DISTANCE_METERS/QUAD_WIDTH_METERS in
|
||||
// xr_session.c, 2m out) so both are visible together without overlapping.
|
||||
#define MENU_DISTANCE_METERS 1.5f
|
||||
#define MENU_WIDTH_METERS 0.8f
|
||||
#define MENU_CENTER_X_METERS 0.0f
|
||||
#define MENU_CENTER_Y_METERS -0.45f
|
||||
|
||||
static bool g_visible = false;
|
||||
|
||||
static jclass g_menu_overlay_class = NULL;
|
||||
static jmethodID g_native_init_method = NULL;
|
||||
static jmethodID g_take_pixels_method = NULL;
|
||||
static jmethodID g_dispatch_touch_method = NULL;
|
||||
|
||||
// The menu overlay's content lives in this texture, uploaded by
|
||||
// xr_menu_upload_source_texture_if_dirty() below. It's created through
|
||||
// gl4es (glGenTextures/glBindTexture/glTexImage2D), like any other texture
|
||||
// the engine itself creates, so ordinary glTexSubImage2D uploads against it
|
||||
// work the normal way.
|
||||
static GLuint g_source_texture = 0;
|
||||
// A framebuffer with g_source_texture as its only color attachment, used as
|
||||
// the read source for xr_menu_render_if_visible()'s glBlitFramebuffer()
|
||||
// call. Created via the real (non-gl4es) GLES entry points below: that blit
|
||||
// bypasses gl4es entirely (see xr_menu_render_if_visible() for why), so its
|
||||
// source framebuffer has to be a real GL object rather than one gl4es
|
||||
// tracks.
|
||||
static GLuint g_source_fbo = 0;
|
||||
|
||||
typedef void (*PFNQSGETINTEGERV)(GLenum, GLint *);
|
||||
typedef void (*PFNQSGENFRAMEBUFFERS)(GLsizei, GLuint *);
|
||||
typedef void (*PFNQSDELETEFRAMEBUFFERS)(GLsizei, const GLuint *);
|
||||
typedef void (*PFNQSBINDFRAMEBUFFER)(GLenum, GLuint);
|
||||
typedef void (*PFNQSFRAMEBUFFERTEXTURE2D)(GLenum, GLenum, GLenum, GLuint, GLint);
|
||||
typedef void (*PFNQSBLITFRAMEBUFFER)(GLint, GLint, GLint, GLint, GLint, GLint, GLint, GLint,
|
||||
GLbitfield, GLenum);
|
||||
|
||||
// The menu's source framebuffer and its glBlitFramebuffer() copy into the
|
||||
// swapchain image go through the real GLES driver rather than gl4es: gl4es's
|
||||
// fixed-pipeline emulation unconditionally substitutes its own shader onto
|
||||
// any gl4es-routed draw call, which would silently replace the menu's
|
||||
// content with whatever the game itself last rendered (see
|
||||
// xr_menu_render_if_visible()). A framebuffer blit has no shader stage at
|
||||
// all, so going through the real driver for it sidesteps the problem
|
||||
// entirely.
|
||||
static PFNQSGETINTEGERV real_glGetIntegerv;
|
||||
static PFNQSGENFRAMEBUFFERS real_glGenFramebuffers;
|
||||
static PFNQSDELETEFRAMEBUFFERS real_glDeleteFramebuffers;
|
||||
static PFNQSBINDFRAMEBUFFER real_glBindFramebuffer;
|
||||
static PFNQSFRAMEBUFFERTEXTURE2D real_glFramebufferTexture2D;
|
||||
// glBlitFramebuffer is a GLES 3.0 addition; resolved via eglGetProcAddress
|
||||
// rather than dlsym, since Android's driver dispatch doesn't guarantee ES3+
|
||||
// symbols are dlsym-able by name from libGLESv2.so, unlike the GLES2-core
|
||||
// functions above.
|
||||
static PFNQSBLITFRAMEBUFFER real_glBlitFramebuffer;
|
||||
|
||||
static bool xr_menu_load_real_gles(void) {
|
||||
void *lib = dlopen("libGLESv2.so", RTLD_NOW | RTLD_LOCAL);
|
||||
if (lib == NULL) {
|
||||
LOGE("XR: menu dlopen(libGLESv2.so) failed: %s", dlerror());
|
||||
return false;
|
||||
}
|
||||
real_glGetIntegerv = (PFNQSGETINTEGERV)dlsym(lib, "glGetIntegerv");
|
||||
real_glGenFramebuffers = (PFNQSGENFRAMEBUFFERS)dlsym(lib, "glGenFramebuffers");
|
||||
real_glDeleteFramebuffers = (PFNQSDELETEFRAMEBUFFERS)dlsym(lib, "glDeleteFramebuffers");
|
||||
real_glBindFramebuffer = (PFNQSBINDFRAMEBUFFER)dlsym(lib, "glBindFramebuffer");
|
||||
real_glFramebufferTexture2D = (PFNQSFRAMEBUFFERTEXTURE2D)dlsym(lib, "glFramebufferTexture2D");
|
||||
real_glBlitFramebuffer = (PFNQSBLITFRAMEBUFFER)eglGetProcAddress("glBlitFramebuffer");
|
||||
return real_glGetIntegerv && real_glGenFramebuffers && real_glDeleteFramebuffers &&
|
||||
real_glBindFramebuffer && real_glFramebufferTexture2D && real_glBlitFramebuffer;
|
||||
}
|
||||
|
||||
static uint32_t g_pixel_generation = 0;
|
||||
static uint32_t g_source_uploaded_generation = (uint32_t)-1;
|
||||
static uint8_t *g_pixel_cache = NULL; // MENU_WIDTH*MENU_HEIGHT*4 bytes
|
||||
|
||||
void xr_menu_get_content_size(int *width, int *height) {
|
||||
*width = MENU_WIDTH;
|
||||
*height = MENU_HEIGHT;
|
||||
}
|
||||
|
||||
// FindClass() from this thread (SDL's native thread, attached to the JVM
|
||||
// via AttachCurrentThread rather than spawned from Java) resolves against
|
||||
// the bootstrap classloader, which only knows framework classes - it can't
|
||||
// see de.ladkau.questshock.MenuOverlay at all. Routing through the
|
||||
// activity's own classloader is the standard, documented workaround.
|
||||
static jclass xr_menu_find_class(JNIEnv *env, jobject activity, const char *name) {
|
||||
jclass activityClass = (*env)->GetObjectClass(env, activity);
|
||||
jmethodID getClassLoader =
|
||||
(*env)->GetMethodID(env, activityClass, "getClassLoader", "()Ljava/lang/ClassLoader;");
|
||||
jobject classLoader = (*env)->CallObjectMethod(env, activity, getClassLoader);
|
||||
jclass classLoaderClass = (*env)->FindClass(env, "java/lang/ClassLoader");
|
||||
jmethodID loadClass = (*env)->GetMethodID(env, classLoaderClass, "loadClass",
|
||||
"(Ljava/lang/String;)Ljava/lang/Class;");
|
||||
jstring className = (*env)->NewStringUTF(env, name);
|
||||
jclass result = (jclass)(*env)->CallObjectMethod(env, classLoader, loadClass, className);
|
||||
(*env)->DeleteLocalRef(env, className);
|
||||
return result;
|
||||
}
|
||||
|
||||
static bool xr_menu_init_jni(void) {
|
||||
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
|
||||
jobject activity = (jobject)SDL_AndroidGetActivity();
|
||||
if (env == NULL || activity == NULL) {
|
||||
LOGE("XR: menu - no JNIEnv/Activity from SDL");
|
||||
return false;
|
||||
}
|
||||
|
||||
jclass localClass = xr_menu_find_class(env, activity, "de.ladkau.questshock.MenuOverlay");
|
||||
if (localClass == NULL) {
|
||||
LOGE("XR: menu - could not find MenuOverlay class");
|
||||
return false;
|
||||
}
|
||||
g_menu_overlay_class = (jclass)(*env)->NewGlobalRef(env, localClass);
|
||||
|
||||
g_native_init_method =
|
||||
(*env)->GetStaticMethodID(env, g_menu_overlay_class, "nativeInit", "(Landroid/app/Activity;)V");
|
||||
g_take_pixels_method =
|
||||
(*env)->GetStaticMethodID(env, g_menu_overlay_class, "nativeTakePixelsIfDirty", "()[B");
|
||||
g_dispatch_touch_method =
|
||||
(*env)->GetStaticMethodID(env, g_menu_overlay_class, "nativeDispatchTouch", "(FFZ)V");
|
||||
if (!g_native_init_method || !g_take_pixels_method || !g_dispatch_touch_method) {
|
||||
LOGE("XR: menu - could not resolve MenuOverlay JNI methods");
|
||||
return false;
|
||||
}
|
||||
|
||||
(*env)->CallStaticVoidMethod(env, g_menu_overlay_class, g_native_init_method, activity);
|
||||
// CallStaticVoidMethod doesn't surface Java exceptions on its own - if
|
||||
// MenuOverlay's constructor throws (it runs its View measure/layout/
|
||||
// draw calls on this native render thread rather than the Android UI
|
||||
// thread, a plausible crash vector), the exception would otherwise be
|
||||
// left silently pending and corrupt whatever JNI call runs next.
|
||||
if ((*env)->ExceptionCheck(env)) {
|
||||
LOGE("XR: menu MenuOverlay.nativeInit() threw a pending Java exception:");
|
||||
(*env)->ExceptionDescribe(env);
|
||||
(*env)->ExceptionClear(env);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool xr_menu_create_source_texture(void) {
|
||||
glGenTextures(1, &g_source_texture);
|
||||
glBindTexture(GL_TEXTURE_2D, g_source_texture);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
|
||||
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, MENU_WIDTH, MENU_HEIGHT, 0, GL_RGBA, GL_UNSIGNED_BYTE,
|
||||
NULL);
|
||||
|
||||
GLint prevFbo = 0;
|
||||
real_glGetIntegerv(GL_FRAMEBUFFER_BINDING, &prevFbo);
|
||||
real_glGenFramebuffers(1, &g_source_fbo);
|
||||
real_glBindFramebuffer(GL_FRAMEBUFFER, g_source_fbo);
|
||||
real_glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D,
|
||||
g_source_texture, 0);
|
||||
real_glBindFramebuffer(GL_FRAMEBUFFER, (GLuint)prevFbo);
|
||||
|
||||
return g_source_texture != 0;
|
||||
}
|
||||
|
||||
bool xr_menu_init(XrInstance instance, XrSession session) {
|
||||
(void)instance;
|
||||
(void)session;
|
||||
|
||||
if (!xr_menu_load_real_gles()) {
|
||||
LOGE("XR: menu couldn't resolve real GLES functions via dlsym/eglGetProcAddress");
|
||||
return false;
|
||||
}
|
||||
if (!xr_menu_create_source_texture())
|
||||
return false;
|
||||
if (!xr_menu_init_jni())
|
||||
return false;
|
||||
|
||||
g_pixel_cache = (uint8_t *)malloc((size_t)MENU_WIDTH * MENU_HEIGHT * 4);
|
||||
if (g_pixel_cache == NULL)
|
||||
return false;
|
||||
|
||||
LOGI("XR: menu overlay ready (%dx%d)", MENU_WIDTH, MENU_HEIGHT);
|
||||
return true;
|
||||
}
|
||||
|
||||
void xr_menu_toggle_visible(void) {
|
||||
g_visible = !g_visible;
|
||||
LOGI("XR: menu quad now %s", g_visible ? "visible" : "hidden");
|
||||
}
|
||||
|
||||
bool xr_menu_is_visible(void) { return g_visible; }
|
||||
|
||||
void xr_menu_get_quad_extent(float *center_x_m, float *center_y_m, float *distance_m,
|
||||
float *half_width_m, float *half_height_m) {
|
||||
*center_x_m = MENU_CENTER_X_METERS;
|
||||
*center_y_m = MENU_CENTER_Y_METERS;
|
||||
*distance_m = MENU_DISTANCE_METERS;
|
||||
*half_width_m = MENU_WIDTH_METERS * 0.5f;
|
||||
*half_height_m = MENU_WIDTH_METERS * 0.5f * (float)MENU_HEIGHT / (float)MENU_WIDTH;
|
||||
}
|
||||
|
||||
void xr_menu_touch(float u, float v, bool down) {
|
||||
if (g_menu_overlay_class == NULL)
|
||||
return;
|
||||
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
|
||||
if (env == NULL)
|
||||
return;
|
||||
// Passed via CallStaticVoidMethodA/jvalue rather than the variadic
|
||||
// Call*Method form - deliberately sidesteps relying on JNI
|
||||
// implementations correctly un-doing C's float-to-double default
|
||||
// argument promotion for varargs float parameters (a well-known JNI
|
||||
// footgun; Android's ART handles it correctly, but there's no reason
|
||||
// to depend on that when the jvalue form is unambiguous either way).
|
||||
jvalue args[3];
|
||||
args[0].f = u;
|
||||
args[1].f = v;
|
||||
args[2].z = (jboolean)down;
|
||||
(*env)->CallStaticVoidMethodA(env, g_menu_overlay_class, g_dispatch_touch_method, args);
|
||||
}
|
||||
|
||||
// Pulls MenuOverlay's latest pixels (if it redrew since the last check)
|
||||
// into g_pixel_cache and bumps g_pixel_generation - called once per visible
|
||||
// frame, before deciding whether g_source_texture needs a fresh upload.
|
||||
static void xr_menu_refresh_pixel_cache(void) {
|
||||
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
|
||||
if (env == NULL)
|
||||
return;
|
||||
jbyteArray pixels =
|
||||
(jbyteArray)(*env)->CallStaticObjectMethod(env, g_menu_overlay_class, g_take_pixels_method);
|
||||
// CallStaticObjectMethod also doesn't surface Java exceptions on its
|
||||
// own - if nativeTakePixelsIfDirty() itself throws, it would otherwise
|
||||
// silently look identical to "nothing changed yet" (a null return).
|
||||
if ((*env)->ExceptionCheck(env)) {
|
||||
LOGE("XR: menu nativeTakePixelsIfDirty() threw a pending Java exception:");
|
||||
(*env)->ExceptionDescribe(env);
|
||||
(*env)->ExceptionClear(env);
|
||||
return;
|
||||
}
|
||||
if (pixels == NULL)
|
||||
return;
|
||||
(*env)->GetByteArrayRegion(env, pixels, 0, MENU_WIDTH * MENU_HEIGHT * 4, (jbyte *)g_pixel_cache);
|
||||
(*env)->DeleteLocalRef(env, pixels);
|
||||
g_pixel_generation++;
|
||||
}
|
||||
|
||||
// Uploads g_pixel_cache into g_source_texture via plain (gl4es-routed)
|
||||
// glBindTexture/glTexSubImage2D, matching how the texture was created.
|
||||
// Skipped when nothing changed since the last upload. Texture-unit-0 state
|
||||
// is saved and restored around the upload, since this call is gl4es-routed
|
||||
// and the engine's own next-frame rendering assumes nothing touched its
|
||||
// texture bindings since it last drew.
|
||||
static void xr_menu_upload_source_texture_if_dirty(void) {
|
||||
if (g_source_uploaded_generation == g_pixel_generation)
|
||||
return;
|
||||
|
||||
GLint prevActiveTexture = GL_TEXTURE0;
|
||||
glGetIntegerv(GL_ACTIVE_TEXTURE, &prevActiveTexture);
|
||||
glActiveTexture(GL_TEXTURE0);
|
||||
GLint prevTexture2D = 0;
|
||||
glGetIntegerv(GL_TEXTURE_BINDING_2D, &prevTexture2D);
|
||||
|
||||
glBindTexture(GL_TEXTURE_2D, g_source_texture);
|
||||
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, MENU_WIDTH, MENU_HEIGHT, GL_RGBA, GL_UNSIGNED_BYTE,
|
||||
g_pixel_cache);
|
||||
g_source_uploaded_generation = g_pixel_generation;
|
||||
|
||||
glBindTexture(GL_TEXTURE_2D, (GLuint)prevTexture2D);
|
||||
if (prevActiveTexture != GL_TEXTURE0)
|
||||
glActiveTexture((GLenum)prevActiveTexture);
|
||||
}
|
||||
|
||||
void xr_menu_render_if_visible(void) {
|
||||
if (g_source_fbo == 0)
|
||||
return;
|
||||
|
||||
xr_menu_refresh_pixel_cache();
|
||||
xr_menu_upload_source_texture_if_dirty();
|
||||
|
||||
// Copies g_source_texture (via g_source_fbo) directly into the menu's
|
||||
// sub-rectangle of the currently-bound shared swapchain framebuffer,
|
||||
// using the real GLES3 hardware blit (glBlitFramebuffer) rather than a
|
||||
// shader-based full-screen-quad draw. A blit has no vertex/fragment
|
||||
// shading stage, no shader program, and no texture units involved at
|
||||
// all, so gl4es's fixed-pipeline-emulation layer - which unconditionally
|
||||
// substitutes a customized shader (reproducing the game's own
|
||||
// last-bound texture/fixed-function state) onto any gl4es-routed draw
|
||||
// call - has nothing to intercept here. It also never touches gl4es's
|
||||
// own tracked program/vertex-array/texture-binding shadow state, so the
|
||||
// only piece of state that needs save/restore is the READ framebuffer
|
||||
// binding; binding only GL_READ_FRAMEBUFFER, rather than the combined
|
||||
// GL_FRAMEBUFFER target, leaves the DRAW side (the swapchain image
|
||||
// itself) untouched throughout.
|
||||
//
|
||||
// Y is flipped between source and destination: g_source_texture's texel
|
||||
// row v=0 holds MenuOverlay's Bitmap row 0 (the top of the rendered
|
||||
// content, standard top-row-first raster order), and reading that same
|
||||
// texture via an attached FBO, framebuffer y=0 accesses that identical
|
||||
// row. The destination swapchain framebuffer follows the opposite
|
||||
// convention - its own y=0 is its bottom edge - so swapping dstY0/dstY1
|
||||
// keeps the menu content right-side up (glBlitFramebuffer supports
|
||||
// inverted src/dst rects for exactly this).
|
||||
GLint prevReadFbo = 0;
|
||||
real_glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &prevReadFbo);
|
||||
GLint viewport[4] = {0, 0, 0, 0};
|
||||
real_glGetIntegerv(GL_VIEWPORT, viewport);
|
||||
|
||||
real_glBindFramebuffer(GL_READ_FRAMEBUFFER, g_source_fbo);
|
||||
real_glBlitFramebuffer(0, 0, MENU_WIDTH, MENU_HEIGHT, viewport[0], viewport[1] + viewport[3],
|
||||
viewport[0] + viewport[2], viewport[1], GL_COLOR_BUFFER_BIT,
|
||||
GL_LINEAR);
|
||||
real_glBindFramebuffer(GL_READ_FRAMEBUFFER, (GLuint)prevReadFbo);
|
||||
}
|
||||
|
||||
void xr_menu_shutdown(void) {
|
||||
if (g_source_texture != 0)
|
||||
glDeleteTextures(1, &g_source_texture);
|
||||
g_source_texture = 0;
|
||||
g_source_uploaded_generation = (uint32_t)-1;
|
||||
|
||||
if (g_source_fbo != 0 && real_glDeleteFramebuffers != NULL)
|
||||
real_glDeleteFramebuffers(1, &g_source_fbo);
|
||||
g_source_fbo = 0;
|
||||
|
||||
if (g_menu_overlay_class != NULL) {
|
||||
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
|
||||
if (env != NULL)
|
||||
(*env)->DeleteGlobalRef(env, g_menu_overlay_class);
|
||||
}
|
||||
g_menu_overlay_class = NULL;
|
||||
g_native_init_method = NULL;
|
||||
g_take_pixels_method = NULL;
|
||||
g_dispatch_touch_method = NULL;
|
||||
|
||||
free(g_pixel_cache);
|
||||
g_pixel_cache = NULL;
|
||||
g_pixel_generation = 0;
|
||||
|
||||
g_visible = false;
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
// Menu quad for questshock's immersive Quest build: MenuOverlay.java's
|
||||
// off-screen-rendered View tree (currently just a "Keyboard" button - see
|
||||
// the plan's step D for the on-screen keyboard that goes behind it),
|
||||
// toggled by xr_input.c's menu_toggle action and hit-tested by the same ray
|
||||
// xr_input.c already computes per hand.
|
||||
//
|
||||
// The menu's content shares xr_session.c's single OpenXR swapchain (see
|
||||
// that file for why: two independently-created swapchains submitted as
|
||||
// separate composition layers make the Horizon OS compositor show the
|
||||
// game's own content on the menu quad instead of the menu's - a
|
||||
// compositor-level limitation, not anything under this app's control).
|
||||
// xr_session.c sizes its shared swapchain to fit both the game's and the
|
||||
// menu's content side by side, and calls into this file to render the
|
||||
// menu's own sub-rectangle each visible frame.
|
||||
#ifndef QUESTSHOCK_XR_MENU_H
|
||||
#define QUESTSHOCK_XR_MENU_H
|
||||
|
||||
#include <stdbool.h>
|
||||
|
||||
#define XR_USE_PLATFORM_ANDROID 1
|
||||
#define XR_USE_GRAPHICS_API_OPENGL_ES 1
|
||||
#include <openxr/openxr.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
// Pixel size of the menu's own content region - xr_session.c needs this
|
||||
// before it creates its shared swapchain, to size it to fit both the
|
||||
// game's and the menu's content. Has no OpenXR/GL dependency, safe to call
|
||||
// before xr_menu_init().
|
||||
void xr_menu_get_content_size(int *width, int *height);
|
||||
|
||||
// Call once, after xr_session.c has created its shared swapchain - sets up
|
||||
// the MenuOverlay Java-side singleton and the gl4es-owned texture its
|
||||
// pixels get uploaded into. Returns false (logged, non-fatal - the caller
|
||||
// just has no menu) on failure.
|
||||
bool xr_menu_init(XrInstance instance, XrSession session);
|
||||
|
||||
// Flips menu-quad visibility - called by xr_input.c on the menu_toggle
|
||||
// action's rising edge.
|
||||
void xr_menu_toggle_visible(void);
|
||||
bool xr_menu_is_visible(void);
|
||||
|
||||
// Local-space pose/size of the menu quad, valid regardless of visibility -
|
||||
// shared with xr_input.c's ray/quad hit-testing, the same way
|
||||
// xr_get_game_quad_extent() (xr_session.h) is for the game quad.
|
||||
void xr_menu_get_quad_extent(float *center_x_m, float *center_y_m, float *distance_m,
|
||||
float *half_width_m, float *half_height_m);
|
||||
|
||||
// Forwards a hit on the menu quad (in the quad's own 0..1 u/v, top-left
|
||||
// origin) to the MenuOverlay Java view as a synthetic touch down/up -
|
||||
// called by xr_input.c on the select_click action's edges, only while a
|
||||
// hand's aim ray currently hits the menu quad.
|
||||
void xr_menu_touch(float u, float v, bool down);
|
||||
|
||||
// Refreshes MenuOverlay's pixels from Java if they changed since the last
|
||||
// call, and blits them into whichever framebuffer is currently bound -
|
||||
// xr_session.c binds its shared swapchain image and sets the
|
||||
// viewport/scissor to the menu's own sub-rectangle within it before calling
|
||||
// this. Only called while xr_menu_is_visible().
|
||||
void xr_menu_render_if_visible(void);
|
||||
|
||||
void xr_menu_shutdown(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// 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 nativeSendPrintableCharDown()/Up() 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
|
||||
@@ -0,0 +1,503 @@
|
||||
#include "xr_overlay.h"
|
||||
|
||||
#include <dlfcn.h>
|
||||
#include <math.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include <android/log.h>
|
||||
#include <jni.h>
|
||||
|
||||
#include <EGL/egl.h>
|
||||
#include <GLES3/gl3.h>
|
||||
|
||||
#include <SDL.h>
|
||||
|
||||
#include "xr_swapchain.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__)
|
||||
|
||||
// Same constant (deliberately not <math.h>'s M_PI, which isn't used
|
||||
// anywhere else in this codebase and isn't guaranteed by the C standard -
|
||||
// only POSIX) as xr_session.c's own play-space rebuild math.
|
||||
#define QS_DEG_TO_RAD (3.14159265358979323846f / 180.0f)
|
||||
|
||||
struct XrOverlay {
|
||||
XrInstance instance; // for error logging only
|
||||
int width, height;
|
||||
float distance_m, half_width_m, half_height_m;
|
||||
float center_x_m, center_y_m;
|
||||
// The *authored* distance_m (config->distance_m at creation time),
|
||||
// never touched again afterward by anything, including
|
||||
// xr_overlay_set_pose() - kept separate from the live, mutable
|
||||
// distance_m above because that one stops meaning "how far in front of
|
||||
// the origin" once a rotated set_pose() call (the menu recenter)
|
||||
// repurposes it as a raw base-space Z coordinate; reusing *that* as a
|
||||
// "how far in front of me" magnitude on a later recenter would drift
|
||||
// further with every reopen instead of staying at a stable distance -
|
||||
// see xr_overlay_get_default_distance_m().
|
||||
float default_distance_m;
|
||||
// Rotation about the vertical axis, applied on top of center_x_m/
|
||||
// center_y_m/distance_m (see xr_overlay_set_pose()) - 0 for every
|
||||
// overlay except the menu launcher immediately after it's freshly
|
||||
// reopened (xr_input.c re-centers it in front of wherever the player
|
||||
// is currently facing, which needs this to keep the quad actually
|
||||
// facing them instead of whatever direction it started out at).
|
||||
float yaw_deg;
|
||||
bool visible;
|
||||
|
||||
XrSwapchainState swapchain;
|
||||
|
||||
jclass java_class;
|
||||
jmethodID native_init_method;
|
||||
jmethodID take_pixels_method;
|
||||
jmethodID dispatch_touch_method;
|
||||
jmethodID update_cursor_method;
|
||||
|
||||
// This overlay's content lives in this texture, uploaded by
|
||||
// xr_overlay_upload_source_texture_if_dirty() below. It's created
|
||||
// through gl4es (glGenTextures/glBindTexture/glTexImage2D), like any
|
||||
// other texture the engine itself creates, so ordinary
|
||||
// glTexSubImage2D uploads against it work the normal way.
|
||||
GLuint source_texture;
|
||||
// A framebuffer with source_texture as its only color attachment, used
|
||||
// as the read source for the blit in
|
||||
// xr_overlay_render_and_build_layer() below. Created via the real
|
||||
// (non-gl4es) GLES entry points below: that blit bypasses gl4es
|
||||
// entirely (see xr_overlay_render_and_build_layer() for why), so its
|
||||
// source framebuffer has to be a real GL object rather than one gl4es
|
||||
// tracks.
|
||||
GLuint source_fbo;
|
||||
|
||||
uint32_t pixel_generation;
|
||||
uint32_t source_uploaded_generation;
|
||||
uint8_t *pixel_cache; // width*height*4 bytes
|
||||
};
|
||||
|
||||
// This file's other GL calls otherwise resolve to gl4es (the only GL
|
||||
// symbol provider linked into this binary - see android/engine-patches/
|
||||
// 02-android-opengl-es.patch), fine for anything shared with the engine's
|
||||
// own gl4es-routed rendering. But the source FBO's blit (see
|
||||
// xr_overlay_render_and_build_layer()) needs the real driver directly:
|
||||
// gl4es's fixed-pipeline emulation unconditionally substitutes its own
|
||||
// shader onto any gl4es-routed draw call, which would silently replace an
|
||||
// overlay's content with whatever the game itself last rendered. A
|
||||
// framebuffer blit has no shader stage at all, so going through the real
|
||||
// driver for it sidesteps the problem entirely - resolved once here and
|
||||
// shared by every XrOverlay instance, since they're process-wide function
|
||||
// pointers regardless of how many overlays exist.
|
||||
typedef void (*PFNQSGETINTEGERV)(GLenum, GLint *);
|
||||
typedef void (*PFNQSGENFRAMEBUFFERS)(GLsizei, GLuint *);
|
||||
typedef void (*PFNQSDELETEFRAMEBUFFERS)(GLsizei, const GLuint *);
|
||||
typedef void (*PFNQSBINDFRAMEBUFFER)(GLenum, GLuint);
|
||||
typedef void (*PFNQSFRAMEBUFFERTEXTURE2D)(GLenum, GLenum, GLenum, GLuint, GLint);
|
||||
typedef void (*PFNQSBLITFRAMEBUFFER)(GLint, GLint, GLint, GLint, GLint, GLint, GLint, GLint,
|
||||
GLbitfield, GLenum);
|
||||
|
||||
static PFNQSGETINTEGERV real_glGetIntegerv;
|
||||
static PFNQSGENFRAMEBUFFERS real_glGenFramebuffers;
|
||||
static PFNQSDELETEFRAMEBUFFERS real_glDeleteFramebuffers;
|
||||
static PFNQSBINDFRAMEBUFFER real_glBindFramebuffer;
|
||||
static PFNQSFRAMEBUFFERTEXTURE2D real_glFramebufferTexture2D;
|
||||
// glBlitFramebuffer is a GLES 3.0 addition; resolved via eglGetProcAddress
|
||||
// rather than dlsym, since Android's driver dispatch doesn't guarantee ES3+
|
||||
// symbols are dlsym-able by name from libGLESv2.so, unlike the GLES2-core
|
||||
// functions above.
|
||||
static PFNQSBLITFRAMEBUFFER real_glBlitFramebuffer;
|
||||
static bool g_real_gles_loaded = false;
|
||||
|
||||
static bool xr_overlay_load_real_gles(void) {
|
||||
if (g_real_gles_loaded)
|
||||
return true;
|
||||
void *lib = dlopen("libGLESv2.so", RTLD_NOW | RTLD_LOCAL);
|
||||
if (lib == NULL) {
|
||||
LOGE("XR: overlay dlopen(libGLESv2.so) failed: %s", dlerror());
|
||||
return false;
|
||||
}
|
||||
real_glGetIntegerv = (PFNQSGETINTEGERV)dlsym(lib, "glGetIntegerv");
|
||||
real_glGenFramebuffers = (PFNQSGENFRAMEBUFFERS)dlsym(lib, "glGenFramebuffers");
|
||||
real_glDeleteFramebuffers = (PFNQSDELETEFRAMEBUFFERS)dlsym(lib, "glDeleteFramebuffers");
|
||||
real_glBindFramebuffer = (PFNQSBINDFRAMEBUFFER)dlsym(lib, "glBindFramebuffer");
|
||||
real_glFramebufferTexture2D = (PFNQSFRAMEBUFFERTEXTURE2D)dlsym(lib, "glFramebufferTexture2D");
|
||||
real_glBlitFramebuffer = (PFNQSBLITFRAMEBUFFER)eglGetProcAddress("glBlitFramebuffer");
|
||||
g_real_gles_loaded = real_glGetIntegerv && real_glGenFramebuffers &&
|
||||
real_glDeleteFramebuffers && real_glBindFramebuffer &&
|
||||
real_glFramebufferTexture2D && real_glBlitFramebuffer;
|
||||
return g_real_gles_loaded;
|
||||
}
|
||||
|
||||
// FindClass() from this thread (SDL's native thread, attached to the JVM
|
||||
// via AttachCurrentThread rather than spawned from Java) resolves against
|
||||
// the bootstrap classloader, which only knows framework classes - it can't
|
||||
// see app classes like de.ladkau.questshock.KeyboardOverlay at all.
|
||||
// Routing through the activity's own classloader is the standard,
|
||||
// documented workaround.
|
||||
static jclass xr_overlay_find_class(JNIEnv *env, jobject activity, const char *name) {
|
||||
jclass activityClass = (*env)->GetObjectClass(env, activity);
|
||||
jmethodID getClassLoader =
|
||||
(*env)->GetMethodID(env, activityClass, "getClassLoader", "()Ljava/lang/ClassLoader;");
|
||||
jobject classLoader = (*env)->CallObjectMethod(env, activity, getClassLoader);
|
||||
jclass classLoaderClass = (*env)->FindClass(env, "java/lang/ClassLoader");
|
||||
jmethodID loadClass = (*env)->GetMethodID(env, classLoaderClass, "loadClass",
|
||||
"(Ljava/lang/String;)Ljava/lang/Class;");
|
||||
jstring className = (*env)->NewStringUTF(env, name);
|
||||
jclass result = (jclass)(*env)->CallObjectMethod(env, classLoader, loadClass, className);
|
||||
(*env)->DeleteLocalRef(env, className);
|
||||
return result;
|
||||
}
|
||||
|
||||
static bool xr_overlay_init_jni(XrOverlay *overlay, const char *java_class_name) {
|
||||
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
|
||||
jobject activity = (jobject)SDL_AndroidGetActivity();
|
||||
if (env == NULL || activity == NULL) {
|
||||
LOGE("XR: overlay - no JNIEnv/Activity from SDL");
|
||||
return false;
|
||||
}
|
||||
|
||||
jclass localClass = xr_overlay_find_class(env, activity, java_class_name);
|
||||
if (localClass == NULL) {
|
||||
LOGE("XR: overlay - could not find class %s", java_class_name);
|
||||
return false;
|
||||
}
|
||||
overlay->java_class = (jclass)(*env)->NewGlobalRef(env, localClass);
|
||||
|
||||
overlay->native_init_method = (*env)->GetStaticMethodID(env, overlay->java_class, "nativeInit",
|
||||
"(Landroid/app/Activity;)V");
|
||||
overlay->take_pixels_method =
|
||||
(*env)->GetStaticMethodID(env, overlay->java_class, "nativeTakePixelsIfDirty", "()[B");
|
||||
overlay->dispatch_touch_method =
|
||||
(*env)->GetStaticMethodID(env, overlay->java_class, "nativeDispatchTouch", "(FFZ)V");
|
||||
overlay->update_cursor_method =
|
||||
(*env)->GetStaticMethodID(env, overlay->java_class, "nativeUpdateCursor", "(FFZ)V");
|
||||
if (!overlay->native_init_method || !overlay->take_pixels_method ||
|
||||
!overlay->dispatch_touch_method || !overlay->update_cursor_method) {
|
||||
LOGE("XR: overlay - could not resolve %s JNI methods", java_class_name);
|
||||
return false;
|
||||
}
|
||||
|
||||
(*env)->CallStaticVoidMethod(env, overlay->java_class, overlay->native_init_method, activity);
|
||||
// CallStaticVoidMethod doesn't surface Java exceptions on its own - if
|
||||
// the constructor throws (it runs its View measure/layout/draw calls
|
||||
// on this native render thread rather than the Android UI thread, a
|
||||
// plausible crash vector), the exception would otherwise be left
|
||||
// silently pending and corrupt whatever JNI call runs next.
|
||||
if ((*env)->ExceptionCheck(env)) {
|
||||
LOGE("XR: overlay %s.nativeInit() threw a pending Java exception:", java_class_name);
|
||||
(*env)->ExceptionDescribe(env);
|
||||
(*env)->ExceptionClear(env);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool xr_overlay_create_source_texture(XrOverlay *overlay) {
|
||||
glGenTextures(1, &overlay->source_texture);
|
||||
glBindTexture(GL_TEXTURE_2D, overlay->source_texture);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
|
||||
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, overlay->width, overlay->height, 0, GL_RGBA,
|
||||
GL_UNSIGNED_BYTE, NULL);
|
||||
|
||||
GLint prevFbo = 0;
|
||||
real_glGetIntegerv(GL_FRAMEBUFFER_BINDING, &prevFbo);
|
||||
real_glGenFramebuffers(1, &overlay->source_fbo);
|
||||
real_glBindFramebuffer(GL_FRAMEBUFFER, overlay->source_fbo);
|
||||
real_glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D,
|
||||
overlay->source_texture, 0);
|
||||
real_glBindFramebuffer(GL_FRAMEBUFFER, (GLuint)prevFbo);
|
||||
|
||||
return overlay->source_texture != 0;
|
||||
}
|
||||
|
||||
XrOverlay *xr_overlay_create(XrInstance instance, XrSession session, int64_t swapchain_format,
|
||||
const XrOverlayConfig *config) {
|
||||
if (!xr_overlay_load_real_gles()) {
|
||||
LOGE("XR: overlay couldn't resolve real GLES functions via dlsym/eglGetProcAddress");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
XrOverlay *overlay = (XrOverlay *)calloc(1, sizeof(XrOverlay));
|
||||
if (overlay == NULL)
|
||||
return NULL;
|
||||
|
||||
overlay->instance = instance;
|
||||
overlay->width = config->width;
|
||||
overlay->height = config->height;
|
||||
overlay->distance_m = config->distance_m;
|
||||
overlay->default_distance_m = config->distance_m;
|
||||
overlay->half_width_m = config->width_m * 0.5f;
|
||||
overlay->half_height_m = config->width_m * 0.5f * (float)config->height / (float)config->width;
|
||||
overlay->center_x_m = config->default_center_x_m;
|
||||
overlay->center_y_m = config->default_center_y_m;
|
||||
|
||||
if (!xr_swapchain_create(instance, session, swapchain_format, config->width, config->height,
|
||||
&overlay->swapchain)) {
|
||||
LOGE("XR: overlay %s swapchain setup failed", config->java_class_name);
|
||||
free(overlay);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
if (!xr_overlay_create_source_texture(overlay)) {
|
||||
xr_swapchain_destroy(&overlay->swapchain);
|
||||
free(overlay);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
if (!xr_overlay_init_jni(overlay, config->java_class_name)) {
|
||||
xr_swapchain_destroy(&overlay->swapchain);
|
||||
free(overlay);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
overlay->pixel_cache = (uint8_t *)malloc((size_t)config->width * config->height * 4);
|
||||
if (overlay->pixel_cache == NULL) {
|
||||
xr_swapchain_destroy(&overlay->swapchain);
|
||||
free(overlay);
|
||||
return NULL;
|
||||
}
|
||||
overlay->source_uploaded_generation = (uint32_t)-1;
|
||||
|
||||
LOGI("XR: overlay %s ready (%dx%d)", config->java_class_name, config->width, config->height);
|
||||
return overlay;
|
||||
}
|
||||
|
||||
void xr_overlay_destroy(XrOverlay *overlay) {
|
||||
if (overlay == NULL)
|
||||
return;
|
||||
|
||||
if (overlay->source_texture != 0)
|
||||
glDeleteTextures(1, &overlay->source_texture);
|
||||
if (overlay->source_fbo != 0 && real_glDeleteFramebuffers != NULL)
|
||||
real_glDeleteFramebuffers(1, &overlay->source_fbo);
|
||||
|
||||
if (overlay->java_class != NULL) {
|
||||
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
|
||||
if (env != NULL)
|
||||
(*env)->DeleteGlobalRef(env, overlay->java_class);
|
||||
}
|
||||
|
||||
xr_swapchain_destroy(&overlay->swapchain);
|
||||
free(overlay->pixel_cache);
|
||||
free(overlay);
|
||||
}
|
||||
|
||||
// Every function below tolerates overlay == NULL (a no-op, or the
|
||||
// obvious "not visible"/zeroed-extent default) - xr_overlay_create() can
|
||||
// fail (e.g. its backing Java class couldn't be resolved), and unlike the
|
||||
// single static-global menu quad this replaced, an overlay instance is
|
||||
// otherwise a heap pointer callers (xr_session.c, questshock_native.c) can
|
||||
// hold onto and use across frames without re-checking for failure every
|
||||
// time.
|
||||
void xr_overlay_toggle_visible(XrOverlay *overlay) {
|
||||
if (overlay != NULL)
|
||||
overlay->visible = !overlay->visible;
|
||||
}
|
||||
|
||||
void xr_overlay_set_visible(XrOverlay *overlay, bool visible) {
|
||||
if (overlay != NULL)
|
||||
overlay->visible = visible;
|
||||
}
|
||||
|
||||
bool xr_overlay_is_visible(const XrOverlay *overlay) { return overlay != NULL && overlay->visible; }
|
||||
|
||||
void xr_overlay_get_quad_extent(const XrOverlay *overlay, float *center_x_m, float *center_y_m,
|
||||
float *distance_m, float *half_width_m, float *half_height_m,
|
||||
float *yaw_deg) {
|
||||
if (overlay == NULL) {
|
||||
*center_x_m = *center_y_m = *distance_m = *half_width_m = *half_height_m = *yaw_deg = 0.0f;
|
||||
return;
|
||||
}
|
||||
*center_x_m = overlay->center_x_m;
|
||||
*center_y_m = overlay->center_y_m;
|
||||
*distance_m = overlay->distance_m;
|
||||
*half_width_m = overlay->half_width_m;
|
||||
*half_height_m = overlay->half_height_m;
|
||||
*yaw_deg = overlay->yaw_deg;
|
||||
}
|
||||
|
||||
void xr_overlay_set_position(XrOverlay *overlay, float center_x_m, float center_y_m) {
|
||||
if (overlay == NULL)
|
||||
return;
|
||||
overlay->center_x_m = center_x_m;
|
||||
overlay->center_y_m = center_y_m;
|
||||
}
|
||||
|
||||
void xr_overlay_set_pose(XrOverlay *overlay, float center_x_m, float center_y_m, float distance_m,
|
||||
float yaw_deg) {
|
||||
if (overlay == NULL)
|
||||
return;
|
||||
overlay->center_x_m = center_x_m;
|
||||
overlay->center_y_m = center_y_m;
|
||||
overlay->distance_m = distance_m;
|
||||
overlay->yaw_deg = yaw_deg;
|
||||
}
|
||||
|
||||
float xr_overlay_get_default_distance_m(const XrOverlay *overlay) {
|
||||
return overlay == NULL ? 0.0f : overlay->default_distance_m;
|
||||
}
|
||||
|
||||
void xr_overlay_touch(XrOverlay *overlay, float u, float v, bool down) {
|
||||
if (overlay == NULL)
|
||||
return;
|
||||
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
|
||||
if (env == NULL)
|
||||
return;
|
||||
// Passed via CallStaticVoidMethodA/jvalue rather than the variadic
|
||||
// Call*Method form - deliberately sidesteps relying on JNI
|
||||
// implementations correctly un-doing C's float-to-double default
|
||||
// argument promotion for varargs float parameters (a well-known JNI
|
||||
// footgun; Android's ART handles it correctly, but there's no reason
|
||||
// to depend on that when the jvalue form is unambiguous either way).
|
||||
jvalue args[3];
|
||||
args[0].f = u;
|
||||
args[1].f = v;
|
||||
args[2].z = (jboolean)down;
|
||||
(*env)->CallStaticVoidMethodA(env, overlay->java_class, overlay->dispatch_touch_method, args);
|
||||
}
|
||||
|
||||
void xr_overlay_update_cursor(XrOverlay *overlay, float u, float v, bool visible) {
|
||||
if (overlay == NULL)
|
||||
return;
|
||||
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
|
||||
if (env == NULL)
|
||||
return;
|
||||
// Same jvalue-form rationale as xr_overlay_touch() above.
|
||||
jvalue args[3];
|
||||
args[0].f = u;
|
||||
args[1].f = v;
|
||||
args[2].z = (jboolean)visible;
|
||||
(*env)->CallStaticVoidMethodA(env, overlay->java_class, overlay->update_cursor_method, args);
|
||||
}
|
||||
|
||||
// Pulls the Java view's latest pixels (if it redrew since the last check)
|
||||
// into pixel_cache and bumps pixel_generation - called once per rendered
|
||||
// frame, before deciding whether source_texture needs a fresh upload.
|
||||
static void xr_overlay_refresh_pixel_cache(XrOverlay *overlay) {
|
||||
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
|
||||
if (env == NULL)
|
||||
return;
|
||||
jbyteArray pixels = (jbyteArray)(*env)->CallStaticObjectMethod(
|
||||
env, overlay->java_class, overlay->take_pixels_method);
|
||||
// CallStaticObjectMethod also doesn't surface Java exceptions on its
|
||||
// own - if nativeTakePixelsIfDirty() itself throws, it would otherwise
|
||||
// silently look identical to "nothing changed yet" (a null return).
|
||||
if ((*env)->ExceptionCheck(env)) {
|
||||
LOGE("XR: overlay nativeTakePixelsIfDirty() threw a pending Java exception:");
|
||||
(*env)->ExceptionDescribe(env);
|
||||
(*env)->ExceptionClear(env);
|
||||
return;
|
||||
}
|
||||
if (pixels == NULL)
|
||||
return;
|
||||
(*env)->GetByteArrayRegion(env, pixels, 0, overlay->width * overlay->height * 4,
|
||||
(jbyte *)overlay->pixel_cache);
|
||||
(*env)->DeleteLocalRef(env, pixels);
|
||||
overlay->pixel_generation++;
|
||||
}
|
||||
|
||||
// Uploads pixel_cache into source_texture via plain (gl4es-routed)
|
||||
// glBindTexture/glTexSubImage2D, matching how the texture was created.
|
||||
// Skipped when nothing changed since the last upload. Texture-unit-0 state
|
||||
// is saved and restored around the upload, since this call is gl4es-routed
|
||||
// and the engine's own next-frame rendering assumes nothing touched its
|
||||
// texture bindings since it last drew.
|
||||
static void xr_overlay_upload_source_texture_if_dirty(XrOverlay *overlay) {
|
||||
if (overlay->source_uploaded_generation == overlay->pixel_generation)
|
||||
return;
|
||||
|
||||
GLint prevActiveTexture = GL_TEXTURE0;
|
||||
glGetIntegerv(GL_ACTIVE_TEXTURE, &prevActiveTexture);
|
||||
glActiveTexture(GL_TEXTURE0);
|
||||
GLint prevTexture2D = 0;
|
||||
glGetIntegerv(GL_TEXTURE_BINDING_2D, &prevTexture2D);
|
||||
|
||||
glBindTexture(GL_TEXTURE_2D, overlay->source_texture);
|
||||
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, overlay->width, overlay->height, GL_RGBA,
|
||||
GL_UNSIGNED_BYTE, overlay->pixel_cache);
|
||||
overlay->source_uploaded_generation = overlay->pixel_generation;
|
||||
|
||||
glBindTexture(GL_TEXTURE_2D, (GLuint)prevTexture2D);
|
||||
if (prevActiveTexture != GL_TEXTURE0)
|
||||
glActiveTexture((GLenum)prevActiveTexture);
|
||||
}
|
||||
|
||||
bool xr_overlay_render_and_build_layer(XrOverlay *overlay, XrCompositionLayerQuad *out_quad) {
|
||||
if (overlay == NULL || !overlay->visible)
|
||||
return false;
|
||||
if (!xr_swapchain_acquire(overlay->instance, &overlay->swapchain))
|
||||
return false;
|
||||
|
||||
xr_overlay_refresh_pixel_cache(overlay);
|
||||
xr_overlay_upload_source_texture_if_dirty(overlay);
|
||||
|
||||
// Copies source_texture (via source_fbo) directly into the swapchain
|
||||
// image xr_swapchain_acquire() just bound - sized to exactly
|
||||
// width x height, so the destination is always the whole image - using
|
||||
// the real GLES3 hardware blit (glBlitFramebuffer) rather than a
|
||||
// shader-based full-screen-quad draw. A blit has no vertex/fragment
|
||||
// shading stage, no shader program, and no texture units involved at
|
||||
// all, so gl4es's fixed-pipeline-emulation layer - which
|
||||
// unconditionally substitutes a customized shader (reproducing the
|
||||
// game's own last-bound texture/fixed-function state) onto any
|
||||
// gl4es-routed draw call - has nothing to intercept here. It also
|
||||
// never touches gl4es's own tracked program/vertex-array/
|
||||
// texture-binding shadow state, so the only piece of state that needs
|
||||
// save/restore is the READ framebuffer binding; binding only
|
||||
// GL_READ_FRAMEBUFFER, rather than the combined GL_FRAMEBUFFER target,
|
||||
// leaves the DRAW side (the swapchain image itself) untouched
|
||||
// throughout.
|
||||
//
|
||||
// Y is flipped between source and destination: source_texture's texel
|
||||
// row v=0 holds the Java view's Bitmap row 0 (the top of the rendered
|
||||
// content, standard top-row-first raster order), and reading that same
|
||||
// texture via an attached FBO, framebuffer y=0 accesses that identical
|
||||
// row. The destination swapchain framebuffer follows the opposite
|
||||
// convention - its own y=0 is its bottom edge - so swapping dstY0/dstY1
|
||||
// keeps the content right-side up (glBlitFramebuffer supports inverted
|
||||
// src/dst rects for exactly this).
|
||||
GLint prevReadFbo = 0;
|
||||
real_glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &prevReadFbo);
|
||||
|
||||
real_glBindFramebuffer(GL_READ_FRAMEBUFFER, overlay->source_fbo);
|
||||
real_glBlitFramebuffer(0, 0, overlay->width, overlay->height, 0, overlay->height,
|
||||
overlay->width, 0, GL_COLOR_BUFFER_BIT, GL_LINEAR);
|
||||
real_glBindFramebuffer(GL_READ_FRAMEBUFFER, (GLuint)prevReadFbo);
|
||||
|
||||
// Without this, nothing here waits for the blit to actually finish on
|
||||
// the GPU before xr_swapchain_release() hands the image back to the
|
||||
// compositor - glBlitFramebuffer only queues the copy. A cross-process
|
||||
// consumer reading the image before the driver has finished writing it
|
||||
// is a textbook source of the diagonal-banded/torn corruption seen on
|
||||
// device (see xr_swapchain_acquire()'s counterpart in OpenGL.cc, which
|
||||
// had the same gap).
|
||||
glFinish();
|
||||
|
||||
xr_swapchain_release(overlay->instance, &overlay->swapchain);
|
||||
|
||||
memset(out_quad, 0, sizeof(*out_quad));
|
||||
out_quad->type = XR_TYPE_COMPOSITION_LAYER_QUAD;
|
||||
out_quad->subImage.swapchain = overlay->swapchain.swapchain;
|
||||
out_quad->subImage.imageRect.extent.width = overlay->swapchain.width;
|
||||
out_quad->subImage.imageRect.extent.height = overlay->swapchain.height;
|
||||
// Rotation about the vertical axis only (see the struct field's own
|
||||
// comment) - same R_y(-yaw_deg) quaternion construction as
|
||||
// xr_session.c's own play-space rebuild, for the same reason: this
|
||||
// quad's pose is expressed as "yaw_deg turned from its own local -Z",
|
||||
// so a point/ray in that rotated frame maps back into the (unrotated)
|
||||
// base space by rotating by -yaw_deg.
|
||||
float half = -(overlay->yaw_deg * QS_DEG_TO_RAD) * 0.5f;
|
||||
out_quad->pose.orientation.x = 0.0f;
|
||||
out_quad->pose.orientation.y = sinf(half);
|
||||
out_quad->pose.orientation.z = 0.0f;
|
||||
out_quad->pose.orientation.w = cosf(half);
|
||||
out_quad->pose.position.x = overlay->center_x_m;
|
||||
out_quad->pose.position.y = overlay->center_y_m;
|
||||
out_quad->pose.position.z = -overlay->distance_m;
|
||||
out_quad->size.width = overlay->half_width_m * 2.0f;
|
||||
out_quad->size.height = overlay->half_height_m * 2.0f;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// Generic "off-screen Android View rendered into its own OpenXR quad,
|
||||
// hit-tested/touched via a laser pointer" module - each instance owns its
|
||||
// own swapchain (via xr_swapchain.h), its own visible/position state, and
|
||||
// the JNI glue to a backing Java class (menu launcher, keyboard, or any
|
||||
// future such panel) that must expose these static methods, matching the
|
||||
// pattern MenuOverlay.java already established:
|
||||
// static void nativeInit(Activity activity)
|
||||
// static byte[] nativeTakePixelsIfDirty()
|
||||
// static void nativeDispatchTouch(float u, float v, boolean down)
|
||||
// static void nativeUpdateCursor(float u, float v, boolean visible)
|
||||
//
|
||||
// xr_input.c drives touch/cursor dispatch and (for panels that support it,
|
||||
// like the keyboard's title bar) dragging via xr_overlay_set_position();
|
||||
// xr_session.c owns instance lifetime and calls
|
||||
// xr_overlay_render_and_build_layer() once per frame per instance.
|
||||
#ifndef QUESTSHOCK_XR_OVERLAY_H
|
||||
#define QUESTSHOCK_XR_OVERLAY_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#define XR_USE_PLATFORM_ANDROID 1
|
||||
#define XR_USE_GRAPHICS_API_OPENGL_ES 1
|
||||
#include <openxr/openxr.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef struct XrOverlay XrOverlay;
|
||||
|
||||
typedef struct {
|
||||
// Fully-qualified Java class name backing this overlay, e.g.
|
||||
// "de.ladkau.questshock.KeyboardOverlay" - resolved via the activity's
|
||||
// own ClassLoader (see xr_overlay_find_class() in xr_overlay.c).
|
||||
const char *java_class_name;
|
||||
// Pixel size of the overlay's own content/swapchain.
|
||||
int width;
|
||||
int height;
|
||||
// Initial local-space pose (meters, in front of the viewer along -Z) -
|
||||
// see xr_overlay_get_quad_extent(). width_m is the quad's physical
|
||||
// width; its height is derived to preserve width/height's aspect.
|
||||
float distance_m;
|
||||
float width_m;
|
||||
float default_center_x_m;
|
||||
float default_center_y_m;
|
||||
} XrOverlayConfig;
|
||||
|
||||
// instance/session are used to create this overlay's own swapchain
|
||||
// (format shared with every other quad this app submits - see
|
||||
// xr_session.c's chosenFormat). Returns NULL (logged) on failure - the
|
||||
// caller just has no such panel.
|
||||
XrOverlay *xr_overlay_create(XrInstance instance, XrSession session, int64_t swapchain_format,
|
||||
const XrOverlayConfig *config);
|
||||
void xr_overlay_destroy(XrOverlay *overlay);
|
||||
|
||||
void xr_overlay_toggle_visible(XrOverlay *overlay);
|
||||
void xr_overlay_set_visible(XrOverlay *overlay, bool visible);
|
||||
bool xr_overlay_is_visible(const XrOverlay *overlay);
|
||||
|
||||
// Base-space pose/size of the quad, valid regardless of visibility - shared
|
||||
// with xr_input.c's ray/quad hit-testing. yaw_deg is rotation about the
|
||||
// vertical axis (see xr_overlay_set_pose()) - 0 unless something has
|
||||
// called that.
|
||||
void xr_overlay_get_quad_extent(const XrOverlay *overlay, float *center_x_m, float *center_y_m,
|
||||
float *distance_m, float *half_width_m, float *half_height_m,
|
||||
float *yaw_deg);
|
||||
|
||||
// Repositions the quad (base-space X/Y offset, same units/space as
|
||||
// xr_overlay_get_quad_extent()'s center_x_m/center_y_m) - e.g. driven by
|
||||
// xr_input.c while the user drags a panel's title bar. Distance from the
|
||||
// viewer and rotation aren't adjustable this way - only left/right/up/down
|
||||
// repositioning, not push/pull or turning - see xr_overlay_set_pose() for
|
||||
// that.
|
||||
void xr_overlay_set_position(XrOverlay *overlay, float center_x_m, float center_y_m);
|
||||
|
||||
// Full reposition: X/Y offset, distance ("push/pull"), and rotation about
|
||||
// the vertical axis (degrees, same sign convention as
|
||||
// xr_session_set_player_transform()'s yaw_deg) - used by xr_input.c to
|
||||
// re-center the menu launcher in front of wherever the player currently
|
||||
// is/facing each time it's freshly reopened, unlike
|
||||
// xr_overlay_set_position()'s drag-only X/Y-at-fixed-distance-and-facing.
|
||||
void xr_overlay_set_pose(XrOverlay *overlay, float center_x_m, float center_y_m, float distance_m,
|
||||
float yaw_deg);
|
||||
|
||||
// The overlay's original, never-mutated distance_m (from XrOverlayConfig at
|
||||
// creation time) - unlike xr_overlay_get_quad_extent()'s live distance_m,
|
||||
// which xr_overlay_set_pose() repurposes as a raw base-space Z coordinate
|
||||
// once any rotation is involved (see xr_overlay_set_pose()'s own comment),
|
||||
// this stays a stable "how far in front" magnitude across repeated
|
||||
// recenters. 0 for a NULL overlay.
|
||||
float xr_overlay_get_default_distance_m(const XrOverlay *overlay);
|
||||
|
||||
// Forwards a hit on the quad (in the quad's own 0..1 u/v, top-left origin)
|
||||
// to the backing Java view as a synthetic touch down/up.
|
||||
void xr_overlay_touch(XrOverlay *overlay, float u, float v, bool down);
|
||||
|
||||
// Updates the on-quad cursor the Java view draws at the current aim hit
|
||||
// point (same u/v convention as xr_overlay_touch()) - called once per
|
||||
// frame regardless of click state, so the cursor tracks the ray
|
||||
// continuously. visible=false hides it.
|
||||
void xr_overlay_update_cursor(XrOverlay *overlay, float u, float v, bool visible);
|
||||
|
||||
// If visible: acquires this overlay's next swapchain image, blits the
|
||||
// Java view's latest rendered pixels into it, releases the image, and
|
||||
// fills *out_quad's subImage/pose/size. space/eyeVisibility/layerFlags are
|
||||
// left for the caller to set (shared/policy choices across every layer
|
||||
// this app submits, e.g. translucency - not this module's concern).
|
||||
// Returns false (out_quad untouched) if not visible or something failed -
|
||||
// the caller should skip submitting a layer for it this frame.
|
||||
bool xr_overlay_render_and_build_layer(XrOverlay *overlay, XrCompositionLayerQuad *out_quad);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "xr_session.h"
|
||||
|
||||
#include <dlfcn.h>
|
||||
#include <ctype.h>
|
||||
#include <math.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
@@ -18,7 +19,8 @@
|
||||
#include <SDL.h>
|
||||
|
||||
#include "xr_input.h"
|
||||
#include "xr_menu.h"
|
||||
#include "xr_overlay.h"
|
||||
#include "xr_swapchain.h"
|
||||
|
||||
#define TAG "QuestShock"
|
||||
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__)
|
||||
@@ -33,14 +35,61 @@
|
||||
#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
|
||||
// The menu launcher (just a "Keyboard" button - see MenuOverlay.java) is
|
||||
// small and sits above where the keyboard defaults to; the keyboard keeps
|
||||
// its previous size/position. They're independent XrOverlay instances (see
|
||||
// xr_overlay.h) so both can be shown/hidden/dragged separately - deliberately
|
||||
// positioned apart by default so they don't start out overlapping.
|
||||
#define MENU_JAVA_CLASS "de.ladkau.questshock.MenuOverlay"
|
||||
// 768x552 (was 512x256, then 768x512) - grown to fit the header/tabs/Video
|
||||
// Options content added on top of the original single "Keyboard" button,
|
||||
// then again to fit the stats ribbon below the tab bar; taller in world
|
||||
// space too since MENU_WIDTH_METERS (the physical size) stays fixed and
|
||||
// height_m is derived from it by aspect ratio (see xr_overlay.c's
|
||||
// xr_overlay_init()).
|
||||
#define MENU_WIDTH 768
|
||||
#define MENU_HEIGHT 552
|
||||
#define MENU_DISTANCE_METERS 1.3f
|
||||
// Physical size - was 0.35m, too small to read comfortably at
|
||||
// MENU_DISTANCE_METERS given how much text the header/tabs/stats ribbon
|
||||
// pack in; same pixel resolution (MENU_WIDTH/HEIGHT above) just displayed
|
||||
// bigger, not a layout change.
|
||||
#define MENU_WIDTH_METERS 0.5f
|
||||
#define MENU_DEFAULT_CENTER_X_METERS 0.0f
|
||||
#define MENU_DEFAULT_CENTER_Y_METERS 0.15f
|
||||
|
||||
#define KEYBOARD_JAVA_CLASS "de.ladkau.questshock.KeyboardOverlay"
|
||||
#define KEYBOARD_WIDTH 1024
|
||||
#define KEYBOARD_HEIGHT 768
|
||||
#define KEYBOARD_DISTANCE_METERS 1.5f
|
||||
#define KEYBOARD_WIDTH_METERS 0.8f
|
||||
#define KEYBOARD_DEFAULT_CENTER_X_METERS 0.0f
|
||||
#define KEYBOARD_DEFAULT_CENTER_Y_METERS -0.45f
|
||||
|
||||
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;
|
||||
|
||||
// Everything this app submits/locates (game/menu/keyboard/beam quads, hand
|
||||
// and head poses) is expressed relative to g_play_space, not g_local_space
|
||||
// directly - it's g_local_space translated/rotated by the inverse of the
|
||||
// player's own virtual transform (see xr_session_set_player_transform()'s
|
||||
// own comment in xr_session.h and xr_session_rebuild_play_space() below),
|
||||
// so moving/turning the player moves/turns everything else relative to
|
||||
// them without touching any actual pose-building or hit-testing math
|
||||
// elsewhere in the app - it's all still "relative to the base space",
|
||||
// which just happens to no longer be g_local_space itself.
|
||||
static XrSpace g_play_space = XR_NULL_HANDLE;
|
||||
static float g_player_x = 0.0f;
|
||||
static float g_player_z = 0.0f;
|
||||
static float g_player_yaw_deg = 0.0f;
|
||||
// Set by xr_session_set_player_transform() (including before xr_init() has
|
||||
// even run - see its own comment), cleared once xr_session_rebuild_play_space()
|
||||
// actually rebuilds g_play_space from the current x/z/yaw_deg - checked
|
||||
// once per frame in xr_frame_end() so a stick held at rest costs nothing.
|
||||
static bool g_player_transform_dirty = false;
|
||||
|
||||
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*
|
||||
@@ -54,55 +103,29 @@ static XrSessionState g_session_state = XR_SESSION_STATE_UNKNOWN;
|
||||
// later state.
|
||||
static bool g_session_running = false;
|
||||
|
||||
// A single shared swapchain, wide enough to hold the game's own content
|
||||
// (left) and the menu's content (right) side by side - see xr_frame_end()
|
||||
// for how each gets its own imageRect/viewport/scissor sub-rectangle within
|
||||
// it. Deliberately not two independent swapchains: submitting two
|
||||
// separately-created swapchains as two XrCompositionLayerQuads makes the
|
||||
// Horizon OS compositor show the game's own content on the menu quad
|
||||
// instead of the menu's - a compositor-level limitation with that
|
||||
// configuration, not something fixable from app-side GL/OpenXR code.
|
||||
// Sharing one swapchain avoids it entirely.
|
||||
static XrSwapchain g_swapchain = XR_NULL_HANDLE;
|
||||
static int g_game_width = 0;
|
||||
static int g_game_height = 0;
|
||||
static int g_menu_width = 0;
|
||||
static int g_menu_height = 0;
|
||||
static GLuint g_swapchain_fbos[MAX_SWAPCHAIN_IMAGES];
|
||||
static uint32_t g_swapchain_image_count = 0;
|
||||
static XrSwapchainState g_game_swapchain;
|
||||
static XrOverlay *g_menu_overlay = NULL;
|
||||
static XrOverlay *g_keyboard_overlay = NULL;
|
||||
|
||||
static XrTime g_predicted_display_time = 0;
|
||||
static bool g_frame_should_render = false;
|
||||
static bool g_have_acquired_image = false;
|
||||
static bool g_have_acquired_game_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;
|
||||
}
|
||||
// XR_META_performance_metrics support (see xr_session_init_performance_metrics()
|
||||
// below) - only present if the runtime advertised it at xrCreateInstance
|
||||
// time (checked via xr_instance_extension_supported()), since unlike the
|
||||
// two always-required KHR extensions above, this one is a device-capability
|
||||
// extra that a build shouldn't hard-depend on.
|
||||
static bool g_performance_metrics_supported = false;
|
||||
static PFN_xrSetPerformanceMetricsStateMETA g_xrSetPerformanceMetricsStateMETA = NULL;
|
||||
static PFN_xrQueryPerformanceMetricsCounterMETA g_xrQueryPerformanceMetricsCounterMETA = NULL;
|
||||
// Resolved once, at startup, by string-matching the runtime's own
|
||||
// human-readable counter path names (see xr_session_init_performance_metrics())
|
||||
// rather than hardcoding the exact path strings the spec defines - a
|
||||
// substring match is robust to spec/runtime wording we can't verify without
|
||||
// on-device testing. XR_NULL_PATH (0) if no match was found.
|
||||
static XrPath g_cpu_utilization_path = XR_NULL_PATH;
|
||||
static XrPath g_gpu_utilization_path = XR_NULL_PATH;
|
||||
|
||||
static bool xr_check(XrResult result, const char *what) {
|
||||
if (XR_SUCCEEDED(result))
|
||||
@@ -124,6 +147,106 @@ static PFN_xrVoidFunction xr_get_proc(const char *name) {
|
||||
return fn;
|
||||
}
|
||||
|
||||
// Queried before xrCreateInstance (which is why this takes no instance
|
||||
// handle - xrEnumerateInstanceExtensionProperties is one of the few
|
||||
// functions that works without one) to decide whether it's safe to add
|
||||
// `name` to enabledExtensionNames - passing an unsupported extension there
|
||||
// would fail instance creation outright, unlike a missing optional feature
|
||||
// discovered afterward.
|
||||
static bool xr_instance_extension_supported(const char *name) {
|
||||
uint32_t count = 0;
|
||||
if (!xr_check(xrEnumerateInstanceExtensionProperties(NULL, 0, &count, NULL),
|
||||
"xrEnumerateInstanceExtensionProperties"))
|
||||
return false;
|
||||
XrExtensionProperties *props = (XrExtensionProperties *)malloc(sizeof(XrExtensionProperties) * count);
|
||||
for (uint32_t i = 0; i < count; i++)
|
||||
props[i].type = XR_TYPE_EXTENSION_PROPERTIES;
|
||||
bool supported = false;
|
||||
if (xr_check(xrEnumerateInstanceExtensionProperties(NULL, count, &count, props),
|
||||
"xrEnumerateInstanceExtensionProperties")) {
|
||||
for (uint32_t i = 0; i < count; i++) {
|
||||
if (strcmp(props[i].extensionName, name) == 0) {
|
||||
supported = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
free(props);
|
||||
return supported;
|
||||
}
|
||||
|
||||
// Case-insensitively checks whether `needle` occurs anywhere in `haystack` -
|
||||
// used to identify a counter path (e.g. ".../device/cpu_utilization_average")
|
||||
// by a short, stable substring instead of matching its exact full spelling.
|
||||
static bool xr_path_name_contains(const char *haystack, const char *needle) {
|
||||
size_t hlen = strlen(haystack);
|
||||
char lower[256];
|
||||
if (hlen >= sizeof(lower))
|
||||
hlen = sizeof(lower) - 1;
|
||||
for (size_t i = 0; i < hlen; i++)
|
||||
lower[i] = (char)tolower((unsigned char)haystack[i]);
|
||||
lower[hlen] = '\0';
|
||||
return strstr(lower, needle) != NULL;
|
||||
}
|
||||
|
||||
// Enables XR_META_performance_metrics (if the runtime advertised it - see
|
||||
// xr_instance_extension_supported() above) and resolves the CPU/GPU
|
||||
// device-utilization counter paths xr_session_update_frame_stats() polls
|
||||
// every frame. Called once, right after xrCreateSession succeeds. Leaves
|
||||
// g_cpu_utilization_path/g_gpu_utilization_path at XR_NULL_PATH (their
|
||||
// getters then report -1.0f) if anything here doesn't pan out - this is a
|
||||
// nice-to-have stats display, not something worth failing session bring-up
|
||||
// over.
|
||||
static void xr_session_init_performance_metrics(void) {
|
||||
if (!g_performance_metrics_supported)
|
||||
return;
|
||||
|
||||
PFN_xrEnumeratePerformanceMetricsCounterPathsMETA enumeratePaths =
|
||||
(PFN_xrEnumeratePerformanceMetricsCounterPathsMETA)xr_get_proc(
|
||||
"xrEnumeratePerformanceMetricsCounterPathsMETA");
|
||||
g_xrSetPerformanceMetricsStateMETA = (PFN_xrSetPerformanceMetricsStateMETA)xr_get_proc(
|
||||
"xrSetPerformanceMetricsStateMETA");
|
||||
g_xrQueryPerformanceMetricsCounterMETA = (PFN_xrQueryPerformanceMetricsCounterMETA)xr_get_proc(
|
||||
"xrQueryPerformanceMetricsCounterMETA");
|
||||
if (enumeratePaths == NULL || g_xrSetPerformanceMetricsStateMETA == NULL ||
|
||||
g_xrQueryPerformanceMetricsCounterMETA == NULL)
|
||||
return;
|
||||
|
||||
XrPerformanceMetricsStateMETA state = {XR_TYPE_PERFORMANCE_METRICS_STATE_META};
|
||||
state.enabled = XR_TRUE;
|
||||
if (!xr_check(g_xrSetPerformanceMetricsStateMETA(g_session, &state),
|
||||
"xrSetPerformanceMetricsStateMETA"))
|
||||
return;
|
||||
|
||||
uint32_t pathCount = 0;
|
||||
if (!xr_check(enumeratePaths(g_instance, 0, &pathCount, NULL),
|
||||
"xrEnumeratePerformanceMetricsCounterPathsMETA"))
|
||||
return;
|
||||
XrPath *paths = (XrPath *)malloc(sizeof(XrPath) * pathCount);
|
||||
if (!xr_check(enumeratePaths(g_instance, pathCount, &pathCount, paths),
|
||||
"xrEnumeratePerformanceMetricsCounterPathsMETA")) {
|
||||
free(paths);
|
||||
return;
|
||||
}
|
||||
|
||||
for (uint32_t i = 0; i < pathCount; i++) {
|
||||
char pathName[256];
|
||||
uint32_t pathNameLen = 0;
|
||||
if (!xr_check(xrPathToString(g_instance, paths[i], sizeof(pathName), &pathNameLen, pathName),
|
||||
"xrPathToString"))
|
||||
continue;
|
||||
if (g_cpu_utilization_path == XR_NULL_PATH && xr_path_name_contains(pathName, "cpu_utilization"))
|
||||
g_cpu_utilization_path = paths[i];
|
||||
if (g_gpu_utilization_path == XR_NULL_PATH && xr_path_name_contains(pathName, "gpu_utilization"))
|
||||
g_gpu_utilization_path = paths[i];
|
||||
}
|
||||
free(paths);
|
||||
|
||||
LOGI("XR: performance metrics cpu_utilization=%s gpu_utilization=%s",
|
||||
g_cpu_utilization_path != XR_NULL_PATH ? "found" : "not found",
|
||||
g_gpu_utilization_path != XR_NULL_PATH ? "found" : "not found");
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -158,6 +281,65 @@ static EGLConfig xr_get_current_egl_config(EGLDisplay display, EGLContext contex
|
||||
return config;
|
||||
}
|
||||
|
||||
#define QS_DEG_TO_RAD (3.14159265358979323846f / 180.0f)
|
||||
|
||||
// Destroys/recreates g_play_space from g_local_space, offset by the
|
||||
// inverse of the player's current virtual transform (g_player_x/z/yaw_deg) -
|
||||
// see xr_session_set_player_transform()'s comment in xr_session.h for why
|
||||
// this is the inverse rather than the transform itself. Given
|
||||
// V = Translate(x, 0, z) * Rotate_Y(yaw_deg) (the player's own transform,
|
||||
// "apparent pose in play space = V composed with real pose in local
|
||||
// space"), the new space's poseInReferenceSpace must be V's inverse:
|
||||
// orientation = Rotate_Y(-yaw_deg), position = Rotate_Y(-yaw_deg) * (-x, 0, -z).
|
||||
// Called once, right after g_local_space itself is created (picking up
|
||||
// whatever qs_config_load_startup() already seeded before xr_init() ever
|
||||
// ran), and once per frame from xr_frame_end() whenever
|
||||
// g_player_transform_dirty says the transform actually changed since the
|
||||
// last rebuild - a plain stick-at-rest frame costs nothing beyond that one
|
||||
// bool check. g_session must already exist.
|
||||
static void xr_session_rebuild_play_space(void) {
|
||||
if (g_play_space != XR_NULL_HANDLE) {
|
||||
xrDestroySpace(g_play_space);
|
||||
g_play_space = XR_NULL_HANDLE;
|
||||
}
|
||||
|
||||
float theta = g_player_yaw_deg * QS_DEG_TO_RAD;
|
||||
float half = -theta * 0.5f;
|
||||
float c = cosf(-theta), s = sinf(-theta);
|
||||
float vx = -g_player_x, vz = -g_player_z;
|
||||
|
||||
XrReferenceSpaceCreateInfo spaceInfo = {XR_TYPE_REFERENCE_SPACE_CREATE_INFO};
|
||||
spaceInfo.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_LOCAL;
|
||||
spaceInfo.poseInReferenceSpace.orientation.x = 0.0f;
|
||||
spaceInfo.poseInReferenceSpace.orientation.y = sinf(half);
|
||||
spaceInfo.poseInReferenceSpace.orientation.z = 0.0f;
|
||||
spaceInfo.poseInReferenceSpace.orientation.w = cosf(half);
|
||||
spaceInfo.poseInReferenceSpace.position.x = vx * c + vz * s;
|
||||
spaceInfo.poseInReferenceSpace.position.y = 0.0f;
|
||||
spaceInfo.poseInReferenceSpace.position.z = -vx * s + vz * c;
|
||||
xr_check(xrCreateReferenceSpace(g_session, &spaceInfo, &g_play_space),
|
||||
"xrCreateReferenceSpace(play)");
|
||||
|
||||
g_player_transform_dirty = false;
|
||||
}
|
||||
|
||||
void xr_session_set_player_transform(float x, float z, float yaw_deg) {
|
||||
g_player_x = x;
|
||||
g_player_z = z;
|
||||
g_player_yaw_deg = yaw_deg;
|
||||
g_player_transform_dirty = true;
|
||||
}
|
||||
|
||||
void xr_session_get_player_transform(float *x, float *z, float *yaw_deg) {
|
||||
*x = g_player_x;
|
||||
*z = g_player_z;
|
||||
*yaw_deg = g_player_yaw_deg;
|
||||
}
|
||||
|
||||
XrSpace xr_session_get_local_space(void) { return g_local_space; }
|
||||
|
||||
XrTime xr_session_get_predicted_display_time(void) { return g_predicted_display_time; }
|
||||
|
||||
static bool xr_create_instance_and_session(int game_width, int game_height) {
|
||||
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
|
||||
jobject activity = (jobject)SDL_AndroidGetActivity();
|
||||
@@ -171,9 +353,13 @@ static bool xr_create_instance_and_session(int game_width, int game_height) {
|
||||
if (!xr_initialize_android_loader(vm, activity))
|
||||
return false;
|
||||
|
||||
const char *extensions[] = {
|
||||
g_performance_metrics_supported =
|
||||
xr_instance_extension_supported(XR_META_PERFORMANCE_METRICS_EXTENSION_NAME);
|
||||
|
||||
const char *extensions[3] = {
|
||||
XR_KHR_ANDROID_CREATE_INSTANCE_EXTENSION_NAME,
|
||||
XR_KHR_OPENGL_ES_ENABLE_EXTENSION_NAME,
|
||||
XR_META_PERFORMANCE_METRICS_EXTENSION_NAME,
|
||||
};
|
||||
|
||||
XrInstanceCreateInfoAndroidKHR androidInfo = {XR_TYPE_INSTANCE_CREATE_INFO_ANDROID_KHR};
|
||||
@@ -182,7 +368,7 @@ static bool xr_create_instance_and_session(int game_width, int game_height) {
|
||||
|
||||
XrInstanceCreateInfo createInfo = {XR_TYPE_INSTANCE_CREATE_INFO};
|
||||
createInfo.next = &androidInfo;
|
||||
createInfo.enabledExtensionCount = sizeof(extensions) / sizeof(extensions[0]);
|
||||
createInfo.enabledExtensionCount = g_performance_metrics_supported ? 3 : 2;
|
||||
createInfo.enabledExtensionNames = extensions;
|
||||
strncpy(createInfo.applicationInfo.applicationName, "QuestShock",
|
||||
XR_MAX_APPLICATION_NAME_SIZE - 1);
|
||||
@@ -202,10 +388,12 @@ static bool xr_create_instance_and_session(int game_width, int game_height) {
|
||||
// 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");
|
||||
(PFN_xrGetOpenGLESGraphicsRequirementsKHR)xr_get_proc(
|
||||
"xrGetOpenGLESGraphicsRequirementsKHR");
|
||||
if (getGLESRequirements == NULL)
|
||||
return false;
|
||||
XrGraphicsRequirementsOpenGLESKHR glesRequirements = {XR_TYPE_GRAPHICS_REQUIREMENTS_OPENGL_ES_KHR};
|
||||
XrGraphicsRequirementsOpenGLESKHR glesRequirements = {
|
||||
XR_TYPE_GRAPHICS_REQUIREMENTS_OPENGL_ES_KHR};
|
||||
if (!xr_check(getGLESRequirements(g_instance, g_system_id, &glesRequirements),
|
||||
"xrGetOpenGLESGraphicsRequirementsKHR"))
|
||||
return false;
|
||||
@@ -229,6 +417,8 @@ static bool xr_create_instance_and_session(int game_width, int game_height) {
|
||||
if (!xr_check(xrCreateSession(g_instance, &sessionInfo, &g_session), "xrCreateSession"))
|
||||
return false;
|
||||
|
||||
xr_session_init_performance_metrics();
|
||||
|
||||
XrReferenceSpaceCreateInfo spaceInfo = {XR_TYPE_REFERENCE_SPACE_CREATE_INFO};
|
||||
spaceInfo.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_LOCAL;
|
||||
spaceInfo.poseInReferenceSpace.orientation.w = 1.0f;
|
||||
@@ -236,9 +426,13 @@ 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);
|
||||
// Picks up whatever qs_config_load_startup() already stored into
|
||||
// g_player_x/z/yaw_deg via xr_session_set_player_transform() before
|
||||
// this function ever ran (that call is pure state, no XR API calls, so
|
||||
// it works fine even this early) - so a saved position is already in
|
||||
// effect from the very first frame, not just after the player first
|
||||
// moves a thumbstick.
|
||||
xr_session_rebuild_play_space();
|
||||
|
||||
// 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
|
||||
@@ -246,7 +440,10 @@ static bool xr_create_instance_and_session(int game_width, int game_height) {
|
||||
// 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.
|
||||
// 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);
|
||||
@@ -261,82 +458,42 @@ static bool xr_create_instance_and_session(int game_width, int game_height) {
|
||||
}
|
||||
free(formats);
|
||||
|
||||
g_game_width = game_width;
|
||||
g_game_height = game_height;
|
||||
xr_menu_get_content_size(&g_menu_width, &g_menu_height);
|
||||
// The game's own content occupies (0,0)-(game_width,game_height); the
|
||||
// menu's occupies (game_width,0)-(game_width+menu_width,menu_height) -
|
||||
// side by side in one image, wide enough and tall enough for both.
|
||||
uint32_t sharedWidth = (uint32_t)(g_game_width + g_menu_width);
|
||||
uint32_t sharedHeight = (uint32_t)(g_game_height > g_menu_height ? g_game_height : g_menu_height);
|
||||
// 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);
|
||||
|
||||
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 = sharedWidth;
|
||||
swapchainInfo.height = sharedHeight;
|
||||
swapchainInfo.faceCount = 1;
|
||||
swapchainInfo.arraySize = 1;
|
||||
swapchainInfo.mipCount = 1;
|
||||
if (!xr_check(xrCreateSwapchain(g_session, &swapchainInfo, &g_swapchain),
|
||||
"xrCreateSwapchain"))
|
||||
return false;
|
||||
|
||||
uint32_t imageCount = 0;
|
||||
xrEnumerateSwapchainImages(g_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_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");
|
||||
if (!xr_swapchain_create(g_instance, g_session, chosenFormat, game_width, game_height,
|
||||
&g_game_swapchain)) {
|
||||
LOGE("XR: game swapchain setup failed");
|
||||
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;
|
||||
// Both non-fatal - the game keeps rendering with no such panel if
|
||||
// either fails (e.g. the Java class couldn't be resolved via JNI).
|
||||
XrOverlayConfig menuConfig = {
|
||||
.java_class_name = MENU_JAVA_CLASS,
|
||||
.width = MENU_WIDTH,
|
||||
.height = MENU_HEIGHT,
|
||||
.distance_m = MENU_DISTANCE_METERS,
|
||||
.width_m = MENU_WIDTH_METERS,
|
||||
.default_center_x_m = MENU_DEFAULT_CENTER_X_METERS,
|
||||
.default_center_y_m = MENU_DEFAULT_CENTER_Y_METERS,
|
||||
};
|
||||
g_menu_overlay = xr_overlay_create(g_instance, g_session, chosenFormat, &menuConfig);
|
||||
|
||||
LOGI("XR: instance/session/swapchain ready (%ux%u shared, %dx%d game + %dx%d menu, %u images)",
|
||||
sharedWidth, sharedHeight, g_game_width, g_game_height, g_menu_width, g_menu_height,
|
||||
imageCount);
|
||||
|
||||
// Also non-fatal - the game keeps rendering with no menu quad if this
|
||||
// fails (e.g. MenuOverlay couldn't be resolved via JNI).
|
||||
xr_menu_init(g_instance, g_session);
|
||||
XrOverlayConfig keyboardConfig = {
|
||||
.java_class_name = KEYBOARD_JAVA_CLASS,
|
||||
.width = KEYBOARD_WIDTH,
|
||||
.height = KEYBOARD_HEIGHT,
|
||||
.distance_m = KEYBOARD_DISTANCE_METERS,
|
||||
.width_m = KEYBOARD_WIDTH_METERS,
|
||||
.default_center_x_m = KEYBOARD_DEFAULT_CENTER_X_METERS,
|
||||
.default_center_y_m = KEYBOARD_DEFAULT_CENTER_Y_METERS,
|
||||
};
|
||||
g_keyboard_overlay = xr_overlay_create(g_instance, g_session, chosenFormat, &keyboardConfig);
|
||||
|
||||
LOGI("XR: instance/session ready (game %dx%d, menu %dx%d, keyboard %dx%d)", game_width,
|
||||
game_height, MENU_WIDTH, MENU_HEIGHT, KEYBOARD_WIDTH, KEYBOARD_HEIGHT);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -349,6 +506,9 @@ bool xr_init(int game_width, int game_height) {
|
||||
return true;
|
||||
}
|
||||
|
||||
XrOverlay *xr_session_get_menu_overlay(void) { return g_menu_overlay; }
|
||||
XrOverlay *xr_session_get_keyboard_overlay(void) { return g_keyboard_overlay; }
|
||||
|
||||
void xr_poll_events(void) {
|
||||
if (g_instance == XR_NULL_HANDLE)
|
||||
return;
|
||||
@@ -369,7 +529,8 @@ void xr_poll_events(void) {
|
||||
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");
|
||||
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;
|
||||
@@ -379,12 +540,20 @@ void xr_poll_events(void) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// xrPollEvent() leaves gl4es reporting a stray GL_INVALID_OPERATION on
|
||||
// every call while the session is running - drain it (looped, since
|
||||
// more than one can accumulate across xrPollEvent()'s possibly-multiple
|
||||
// iterations above) so it doesn't surface as a false positive at the
|
||||
// next unrelated glGetError() check in this app's own rendering code.
|
||||
while (glGetError() != GL_NO_ERROR) {
|
||||
}
|
||||
}
|
||||
|
||||
bool xr_is_session_running(void) { return g_session_running; }
|
||||
|
||||
bool xr_frame_begin(void) {
|
||||
g_have_acquired_image = false;
|
||||
g_have_acquired_game_image = false;
|
||||
if (!xr_is_session_running())
|
||||
return false;
|
||||
|
||||
@@ -402,139 +571,188 @@ bool xr_frame_begin(void) {
|
||||
if (!g_frame_should_render)
|
||||
return false;
|
||||
|
||||
uint32_t imageIndex = 0;
|
||||
XrSwapchainImageAcquireInfo acquireInfo = {XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO};
|
||||
if (!xr_check(xrAcquireSwapchainImage(g_swapchain, &acquireInfo, &imageIndex),
|
||||
"xrAcquireSwapchainImage"))
|
||||
return false;
|
||||
|
||||
XrSwapchainImageWaitInfo waitImageInfo = {XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO};
|
||||
waitImageInfo.timeout = XR_INFINITE_DURATION;
|
||||
if (!xr_check(xrWaitSwapchainImage(g_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. Scissor-limited to the game's own sub-rectangle of the shared
|
||||
// image (see g_swapchain's comment) - glViewport alone would already
|
||||
// keep the engine's draw calls within that rectangle, but glClear
|
||||
// ignores viewport and would otherwise wipe the menu's own region of
|
||||
// the same image whenever the engine clears before drawing.
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, g_swapchain_fbos[imageIndex]);
|
||||
glViewport(0, 0, g_game_width, g_game_height);
|
||||
glEnable(GL_SCISSOR_TEST);
|
||||
glScissor(0, 0, g_game_width, g_game_height);
|
||||
|
||||
g_have_acquired_image = true;
|
||||
return true;
|
||||
g_have_acquired_game_image = xr_swapchain_acquire(g_instance, &g_game_swapchain);
|
||||
return g_have_acquired_game_image;
|
||||
}
|
||||
|
||||
// Backs xr_session_get_fps()/get_frame_ms() (see xr_session.h) - exponential
|
||||
// smoothing (rather than a plain per-frame instantaneous value) so
|
||||
// MenuOverlay's header display doesn't visibly jitter every single frame.
|
||||
// Called once per xr_frame_end() that reaches its own end (i.e. once per
|
||||
// real XR frame, whether or not the game itself actually rendered anything
|
||||
// new into it this frame - matches the OpenXR compositor's own frame
|
||||
// cadence, which is what "FPS" means to someone looking at this display).
|
||||
static float s_fps = 0.0f;
|
||||
static float s_frame_ms = 0.0f;
|
||||
static float s_cpu_utilization = -1.0f;
|
||||
static float s_gpu_utilization = -1.0f;
|
||||
|
||||
// Reads one XR_META_performance_metrics counter (if it was found at
|
||||
// startup - see xr_session_init_performance_metrics()) and folds it into
|
||||
// *smoothed with the same exponential smoothing as FPS/frame time above.
|
||||
// Leaves *smoothed at -1.0f (its "unsupported" sentinel) untouched if the
|
||||
// path wasn't found, the query fails, or the runtime didn't mark its
|
||||
// floatValue valid this call.
|
||||
static void xr_session_update_utilization(XrPath path, float *smoothed) {
|
||||
if (path == XR_NULL_PATH || g_xrQueryPerformanceMetricsCounterMETA == NULL)
|
||||
return;
|
||||
XrPerformanceMetricsCounterMETA counter = {XR_TYPE_PERFORMANCE_METRICS_COUNTER_META};
|
||||
if (!xr_check(g_xrQueryPerformanceMetricsCounterMETA(g_session, path, &counter),
|
||||
"xrQueryPerformanceMetricsCounterMETA"))
|
||||
return;
|
||||
if (!(counter.counterFlags & XR_PERFORMANCE_METRICS_COUNTER_FLOAT_VALUE_VALID_BIT_META))
|
||||
return;
|
||||
const float kSmoothing = 0.1f;
|
||||
*smoothed =
|
||||
(*smoothed < 0.0f) ? counter.floatValue : *smoothed * (1.0f - kSmoothing) + counter.floatValue * kSmoothing;
|
||||
}
|
||||
|
||||
static void xr_session_update_frame_stats(void) {
|
||||
static Uint32 s_last_ticks = 0;
|
||||
Uint32 now = SDL_GetTicks();
|
||||
if (s_last_ticks != 0) {
|
||||
Uint32 delta_ms = now - s_last_ticks;
|
||||
if (delta_ms > 0) {
|
||||
float instant_fps = 1000.0f / (float)delta_ms;
|
||||
const float kSmoothing = 0.1f;
|
||||
s_fps = (s_fps == 0.0f) ? instant_fps : s_fps * (1.0f - kSmoothing) + instant_fps * kSmoothing;
|
||||
s_frame_ms =
|
||||
(s_frame_ms == 0.0f) ? (float)delta_ms : s_frame_ms * (1.0f - kSmoothing) + (float)delta_ms * kSmoothing;
|
||||
}
|
||||
}
|
||||
s_last_ticks = now;
|
||||
|
||||
xr_session_update_utilization(g_cpu_utilization_path, &s_cpu_utilization);
|
||||
xr_session_update_utilization(g_gpu_utilization_path, &s_gpu_utilization);
|
||||
}
|
||||
|
||||
float xr_session_get_fps(void) { return s_fps; }
|
||||
|
||||
float xr_session_get_frame_ms(void) { return s_frame_ms; }
|
||||
|
||||
float xr_session_get_cpu_utilization(void) { return s_cpu_utilization; }
|
||||
|
||||
float xr_session_get_gpu_utilization(void) { return s_gpu_utilization; }
|
||||
|
||||
void xr_frame_end(void) {
|
||||
if (g_session == XR_NULL_HANDLE)
|
||||
return;
|
||||
|
||||
// Draw the laser-pointer reticle into the still-bound 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 the menu isn't visible
|
||||
// (see xr_input.c), so it never needs to know about the menu's own
|
||||
// sub-rectangle below.
|
||||
// Rebuilt before xr_input_sync_and_draw() below so this same frame's
|
||||
// hit-testing and layer submission both see whatever the player's
|
||||
// thumbstick input (or a config-driven "Reset Position"/Load
|
||||
// Configuration) changed last frame - a no-op check on every frame
|
||||
// nothing moved.
|
||||
if (g_player_transform_dirty)
|
||||
xr_session_rebuild_play_space();
|
||||
|
||||
// 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_image);
|
||||
xr_input_sync_and_draw(g_play_space, g_predicted_display_time,
|
||||
g_have_acquired_game_image);
|
||||
|
||||
bool menuVisible = g_have_acquired_image && xr_menu_is_visible();
|
||||
if (menuVisible) {
|
||||
// Switch viewport/scissor to the menu's own sub-rectangle of the
|
||||
// same shared image (to the right of the game's own region, see
|
||||
// g_swapchain's comment) and let xr_menu.c draw its content there -
|
||||
// still the same framebuffer bound in xr_frame_begin(), just a
|
||||
// different sub-rectangle of it.
|
||||
glViewport(g_game_width, 0, g_menu_width, g_menu_height);
|
||||
glScissor(g_game_width, 0, g_menu_width, g_menu_height);
|
||||
xr_menu_render_if_visible();
|
||||
}
|
||||
|
||||
if (g_have_acquired_image) {
|
||||
glDisable(GL_SCISSOR_TEST);
|
||||
XrSwapchainImageReleaseInfo releaseInfo = {XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO};
|
||||
xr_check(xrReleaseSwapchainImage(g_swapchain, &releaseInfo), "xrReleaseSwapchainImage");
|
||||
}
|
||||
if (g_have_acquired_game_image)
|
||||
xr_swapchain_release(g_instance, &g_game_swapchain);
|
||||
|
||||
if (!xr_is_session_running())
|
||||
return;
|
||||
|
||||
// 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 - 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.space = g_play_space;
|
||||
gameQuad.eyeVisibility = XR_EYE_VISIBILITY_BOTH;
|
||||
gameQuad.subImage.swapchain = g_swapchain;
|
||||
gameQuad.subImage.imageRect.offset.x = 0;
|
||||
gameQuad.subImage.imageRect.offset.y = 0;
|
||||
gameQuad.subImage.imageRect.extent.width = g_game_width;
|
||||
gameQuad.subImage.imageRect.extent.height = g_game_height;
|
||||
gameQuad.subImage.swapchain = g_game_swapchain.swapchain;
|
||||
gameQuad.subImage.imageRect.extent.width = g_game_swapchain.width;
|
||||
gameQuad.subImage.imageRect.extent.height = g_game_swapchain.height;
|
||||
gameQuad.pose.orientation.w = 1.0f;
|
||||
gameQuad.pose.position.z = -QUAD_DISTANCE_METERS;
|
||||
gameQuad.size.width = QUAD_WIDTH_METERS;
|
||||
gameQuad.size.height = QUAD_WIDTH_METERS * (float)g_game_height / (float)g_game_width;
|
||||
gameQuad.size.height =
|
||||
QUAD_WIDTH_METERS * (float)g_game_swapchain.height / (float)g_game_swapchain.width;
|
||||
|
||||
// Up to 2 layers: the game quad (if a frame was actually rendered) and,
|
||||
// while toggled on, the menu quad in front of it - see xr_input.c's
|
||||
// menu_toggle handling. Both reference the SAME g_swapchain, just
|
||||
// different imageRect sub-rectangles within it.
|
||||
const XrCompositionLayerBaseHeader *layers[2];
|
||||
const XrCompositionLayerBaseHeader *layers[5];
|
||||
uint32_t layerCount = 0;
|
||||
if (g_have_acquired_image)
|
||||
if (g_have_acquired_game_image)
|
||||
layers[layerCount++] = (XrCompositionLayerBaseHeader *)&gameQuad;
|
||||
|
||||
XrCompositionLayerQuad menuQuad = {XR_TYPE_COMPOSITION_LAYER_QUAD};
|
||||
if (menuVisible) {
|
||||
float centerX, centerY, distance, halfWidth, halfHeight;
|
||||
xr_menu_get_quad_extent(¢erX, ¢erY, &distance, &halfWidth, &halfHeight);
|
||||
menuQuad.space = g_local_space;
|
||||
XrCompositionLayerQuad menuQuad;
|
||||
if (g_frame_should_render && xr_overlay_render_and_build_layer(g_menu_overlay, &menuQuad)) {
|
||||
menuQuad.space = g_play_space;
|
||||
menuQuad.eyeVisibility = XR_EYE_VISIBILITY_BOTH;
|
||||
menuQuad.subImage.swapchain = g_swapchain;
|
||||
menuQuad.subImage.imageRect.offset.x = g_game_width;
|
||||
menuQuad.subImage.imageRect.offset.y = 0;
|
||||
menuQuad.subImage.imageRect.extent.width = g_menu_width;
|
||||
menuQuad.subImage.imageRect.extent.height = g_menu_height;
|
||||
menuQuad.pose.orientation.w = 1.0f;
|
||||
menuQuad.pose.position.x = centerX;
|
||||
menuQuad.pose.position.y = centerY;
|
||||
menuQuad.pose.position.z = -distance;
|
||||
menuQuad.size.width = halfWidth * 2.0f;
|
||||
menuQuad.size.height = halfHeight * 2.0f;
|
||||
layers[layerCount++] = (XrCompositionLayerBaseHeader *)&menuQuad;
|
||||
}
|
||||
|
||||
XrCompositionLayerQuad keyboardQuad;
|
||||
if (g_frame_should_render &&
|
||||
xr_overlay_render_and_build_layer(g_keyboard_overlay, &keyboardQuad)) {
|
||||
keyboardQuad.space = g_play_space;
|
||||
keyboardQuad.eyeVisibility = XR_EYE_VISIBILITY_BOTH;
|
||||
// Lets the keyboard sit on screen translucently while playing
|
||||
// rather than fully hiding whatever's behind it - KeyboardOverlay
|
||||
// writes real sub-255 alpha into the swapchain texture (see
|
||||
// OverlayPanel.compositeAndPublish()), already in Android's
|
||||
// default premultiplied format, which is what this flag's absence
|
||||
// of UNPREMULTIPLIED_ALPHA_BIT assumes. The menu launcher above
|
||||
// deliberately doesn't get this flag - it stays fully opaque.
|
||||
keyboardQuad.layerFlags = XR_COMPOSITION_LAYER_BLEND_TEXTURE_SOURCE_ALPHA_BIT;
|
||||
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_play_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;
|
||||
endInfo.layerCount = layerCount;
|
||||
endInfo.layers = layerCount > 0 ? layers : NULL;
|
||||
|
||||
xr_check(xrEndFrame(g_session, &endInfo), "xrEndFrame");
|
||||
|
||||
xr_session_update_frame_stats();
|
||||
}
|
||||
|
||||
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_swapchain != XR_NULL_HANDLE)
|
||||
xrDestroySwapchain(g_swapchain);
|
||||
g_swapchain = XR_NULL_HANDLE;
|
||||
xr_swapchain_destroy(&g_game_swapchain);
|
||||
|
||||
if (g_local_space != XR_NULL_HANDLE)
|
||||
xrDestroySpace(g_local_space);
|
||||
g_local_space = XR_NULL_HANDLE;
|
||||
|
||||
if (g_play_space != XR_NULL_HANDLE)
|
||||
xrDestroySpace(g_play_space);
|
||||
g_play_space = XR_NULL_HANDLE;
|
||||
|
||||
// Before the session/instance they were created from.
|
||||
xr_input_shutdown();
|
||||
xr_menu_shutdown();
|
||||
xr_overlay_destroy(g_menu_overlay);
|
||||
g_menu_overlay = NULL;
|
||||
xr_overlay_destroy(g_keyboard_overlay);
|
||||
g_keyboard_overlay = NULL;
|
||||
|
||||
if (g_session != XR_NULL_HANDLE)
|
||||
xrDestroySession(g_session);
|
||||
@@ -546,10 +764,37 @@ void xr_shutdown(void) {
|
||||
|
||||
g_session_state = XR_SESSION_STATE_UNKNOWN;
|
||||
g_session_running = false;
|
||||
|
||||
g_performance_metrics_supported = false;
|
||||
g_xrSetPerformanceMetricsStateMETA = NULL;
|
||||
g_xrQueryPerformanceMetricsCounterMETA = NULL;
|
||||
g_cpu_utilization_path = XR_NULL_PATH;
|
||||
g_gpu_utilization_path = XR_NULL_PATH;
|
||||
}
|
||||
|
||||
void xr_get_game_quad_extent(float *distance_m, float *half_width_m, float *half_height_m) {
|
||||
*distance_m = QUAD_DISTANCE_METERS;
|
||||
*half_width_m = QUAD_WIDTH_METERS * 0.5f;
|
||||
*half_height_m = QUAD_WIDTH_METERS * 0.5f * (float)g_game_height / (float)g_game_width;
|
||||
*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) {
|
||||
// Not g_game_swapchain.width/height: that's the fixed physical size of
|
||||
// the swapchain image the game is composited into (see xr_init()) -
|
||||
// android_draw_surface_as_quad() (OpenGL.cc, engine-patches/11) always
|
||||
// stretch-blits drawSurface to fill that image regardless of
|
||||
// drawSurface's own pixel size, so the swapchain's size has no relation
|
||||
// to the coordinate space MouseX/MouseY actually live in. gScreenWide/
|
||||
// gScreenHigh (InitMac.h) is drawSurface's current size - the
|
||||
// resolution the in-game options menu can change at runtime
|
||||
// (ShockBitmap.c's ChangeScreenSize()) independently of the swapchain,
|
||||
// which is created once at launch and never resized. Using the fixed
|
||||
// swapchain size here after such a change mapped the laser pointer's
|
||||
// quad-space hit into the wrong coordinate space entirely, moving
|
||||
// MouseX/MouseY out of the engine's actual screen bounds - which is
|
||||
// what made the cursor disappear and become unclickable.
|
||||
extern int32_t gScreenWide, gScreenHigh;
|
||||
*width = gScreenWide;
|
||||
*height = gScreenHigh;
|
||||
}
|
||||
|
||||
@@ -1,28 +1,43 @@
|
||||
// Minimal OpenXR bring-up for questshock's immersive Quest build. Keeps the
|
||||
// game's own rendering untouched (see android/engine-patches/
|
||||
// 11-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.
|
||||
// instance/session/swapchains and the final "submit quad layers instead of
|
||||
// presenting to a window" step. No stereo rendering - 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>
|
||||
|
||||
#include "xr_overlay.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.
|
||||
// instance/session sharing that same EGL display/context, plus the game
|
||||
// quad's own swapchain, sized to the game's logical resolution
|
||||
// (game_width/height, i.e. grd_cap->w/h - see Shock.c's InitSDL()). Also
|
||||
// creates the menu launcher and keyboard overlay quads (see xr_overlay.h) -
|
||||
// two independent XrOverlay instances, each with their own swapchain,
|
||||
// visibility, and position, retrievable via xr_session_get_menu_overlay()/
|
||||
// xr_session_get_keyboard_overlay() below. 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);
|
||||
|
||||
// The menu launcher (small "Keyboard" button panel, toggled by the
|
||||
// controller's menu button) and keyboard (the on-screen key grid, shown/
|
||||
// hidden independently via MenuOverlay's "Keyboard" button and
|
||||
// KeyboardOverlay's own Close button) overlay quads - used by xr_input.c
|
||||
// for ray/quad hit-testing and touch/cursor dispatch, and by
|
||||
// questshock_native.c's JNI glue (nativeShowKeyboard()/
|
||||
// nativeRequestClose()). NULL if xr_init() failed before creating them.
|
||||
XrOverlay *xr_session_get_menu_overlay(void);
|
||||
XrOverlay *xr_session_get_keyboard_overlay(void);
|
||||
|
||||
// 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).
|
||||
@@ -34,27 +49,98 @@ void xr_poll_events(void);
|
||||
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).
|
||||
// this frame rendered, acquires the game quad's 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.
|
||||
// Releases the game quad's swapchain image (if one was acquired this
|
||||
// frame), then does the same acquire/render/release cycle for the menu
|
||||
// launcher and keyboard overlay quads via xr_overlay_render_and_build_layer()
|
||||
// for whichever of them is currently visible, and submits whichever of the
|
||||
// three quads actually rendered this frame as composition layers
|
||||
// positioned in front of the play space's origin (see
|
||||
// xr_session_set_player_transform() above), then ends the XR frame.
|
||||
void xr_frame_end(void);
|
||||
|
||||
void xr_shutdown(void);
|
||||
|
||||
// Local-space geometry of the single game quad xr_frame_end() submits
|
||||
// (distance in front of the local space origin, half-width/half-height,
|
||||
// Play-space geometry of the single game quad xr_frame_end() submits
|
||||
// (distance in front of the play space origin, half-width/half-height,
|
||||
// all in meters) - shared with xr_input.c's ray/quad hit-testing so the
|
||||
// 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);
|
||||
|
||||
// The engine's current screen resolution (gScreenWide/gScreenHigh,
|
||||
// InitMac.h) - not the game quad's own swapchain size, which is fixed at
|
||||
// launch and never changes - 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). Can change at runtime via the in-game
|
||||
// options menu (ShockBitmap.c's ChangeScreenSize()), independently of the
|
||||
// swapchain.
|
||||
void xr_get_game_resolution(int *width, int *height);
|
||||
|
||||
// Exponentially-smoothed frame rate/frame time, updated once per real
|
||||
// rendered frame in xr_frame_end() - read by MenuOverlay's header (see
|
||||
// questshock_native.c's nativeGetFps()/nativeGetFrameTimeMs()). 0 before
|
||||
// the second frame has run (nothing to compute a delta from yet).
|
||||
float xr_session_get_fps(void);
|
||||
float xr_session_get_frame_ms(void);
|
||||
|
||||
// Device-wide CPU/GPU utilization percentage (0-100), via the
|
||||
// XR_META_performance_metrics extension - same per-frame update cadence as
|
||||
// the FPS/frame-time getters above. Returns -1.0f if the extension isn't
|
||||
// supported by the runtime, or its cpu_utilization/gpu_utilization counter
|
||||
// couldn't be found among the ones it advertises.
|
||||
float xr_session_get_cpu_utilization(void);
|
||||
float xr_session_get_gpu_utilization(void);
|
||||
|
||||
// The player's virtual position/orientation - horizontal-plane only (x/z
|
||||
// plus a yaw rotation about the vertical axis); height always comes from
|
||||
// the real tracked HMD pose, never from this. Everything the app submits
|
||||
// (game/menu/keyboard/beam quads) and every hand/head pose it locates is
|
||||
// expressed relative to a second reference space, derived from the real
|
||||
// LOCAL space by this transform's inverse (see xr_session.c's own
|
||||
// xr_session_rebuild_play_space()) - so changing it moves/turns the player
|
||||
// relative to those otherwise-fixed quads, letting them walk up to and
|
||||
// square up with the game quad ("virtual screen") without needing any
|
||||
// physical room-scale space to do it in.
|
||||
//
|
||||
// xr_session_set_player_transform() only stores the three floats (no XR
|
||||
// calls) - safe to call before xr_init() has even run, which is how
|
||||
// qs_config_load_startup() seeds the saved position before the OpenXR
|
||||
// session/spaces exist yet (xr_init() picks it up when it creates its own
|
||||
// spaces); also used live by xr_input.c's thumbstick locomotion and
|
||||
// "Reset Position" recenter (xr_input_recenter_player_to_screen()) and
|
||||
// qs_config.c's "Load Configuration", and read back by "Save Configuration"
|
||||
// via the getter.
|
||||
void xr_session_set_player_transform(float x, float z, float yaw_deg);
|
||||
void xr_session_get_player_transform(float *x, float *z, float *yaw_deg);
|
||||
|
||||
// The real, physical tracking-origin reference space - used only by
|
||||
// xr_input_recenter_player_to_screen() (xr_input.c), which needs the
|
||||
// player's CURRENT real head pose (not one already bent through the
|
||||
// existing play-space transform - see xr_session_set_player_transform()'s
|
||||
// own comment above) to compute a brand new one. Every other consumer in
|
||||
// this app (rendering, hit-testing) uses the play space instead - see
|
||||
// xr_session_rebuild_play_space()'s own comment for why this one stays a
|
||||
// separate, never-swapped-out space.
|
||||
XrSpace xr_session_get_local_space(void);
|
||||
|
||||
// The most recent xrWaitFrame-predicted display time (xr_frame_begin()'s
|
||||
// own g_predicted_display_time) - for the same reason as
|
||||
// xr_session_get_local_space() above, xr_input_recenter_player_to_screen()
|
||||
// needs a valid XrTime to locate a space with but isn't itself called from
|
||||
// the normal per-frame xr_frame_end()/xr_input_sync_and_draw() call chain
|
||||
// that already threads one through (it's triggered by a button press,
|
||||
// dispatched via qs_config.c's cross-thread event queue instead). A frame
|
||||
// or so of staleness doesn't matter for a one-shot recenter. 0 before the
|
||||
// first frame has ever begun.
|
||||
XrTime xr_session_get_predicted_display_time(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
#include "xr_swapchain.h"
|
||||
|
||||
#include <dlfcn.h>
|
||||
|
||||
#include <android/log.h>
|
||||
|
||||
#define TAG "QuestShock"
|
||||
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__)
|
||||
|
||||
static bool xr_check(XrInstance instance, XrResult result, const char *what) {
|
||||
if (XR_SUCCEEDED(result))
|
||||
return true;
|
||||
char resultString[XR_MAX_RESULT_STRING_SIZE] = {0};
|
||||
if (instance != XR_NULL_HANDLE)
|
||||
xrResultToString(instance, result, resultString);
|
||||
LOGE("XR: %s failed: %s (%d)", what, resultString[0] ? resultString : "?", (int)result);
|
||||
return false;
|
||||
}
|
||||
|
||||
// This file's other 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 g_real_gles_loaded = false;
|
||||
|
||||
static bool xr_swapchain_load_real_gles(void) {
|
||||
if (g_real_gles_loaded)
|
||||
return true;
|
||||
void *lib = dlopen("libGLESv2.so", RTLD_NOW | RTLD_LOCAL);
|
||||
if (lib == NULL) {
|
||||
LOGE("XR: swapchain dlopen(libGLESv2.so) failed: %s", dlerror());
|
||||
return false;
|
||||
}
|
||||
real_glFramebufferTexture2D = (PFNQSFRAMEBUFFERTEXTURE2D)dlsym(lib, "glFramebufferTexture2D");
|
||||
real_glCheckFramebufferStatus =
|
||||
(PFNQSCHECKFRAMEBUFFERSTATUS)dlsym(lib, "glCheckFramebufferStatus");
|
||||
g_real_gles_loaded = real_glFramebufferTexture2D && real_glCheckFramebufferStatus;
|
||||
return g_real_gles_loaded;
|
||||
}
|
||||
|
||||
bool xr_swapchain_create(XrInstance instance, XrSession session, int64_t format, int width,
|
||||
int height, XrSwapchainState *out) {
|
||||
if (!xr_swapchain_load_real_gles()) {
|
||||
LOGE("XR: swapchain couldn't resolve real GLES FBO functions via dlsym");
|
||||
return false;
|
||||
}
|
||||
|
||||
out->width = width;
|
||||
out->height = height;
|
||||
|
||||
XrSwapchainCreateInfo swapchainInfo = {XR_TYPE_SWAPCHAIN_CREATE_INFO};
|
||||
swapchainInfo.usageFlags =
|
||||
XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT | XR_SWAPCHAIN_USAGE_SAMPLED_BIT;
|
||||
swapchainInfo.format = format;
|
||||
swapchainInfo.sampleCount = 1;
|
||||
swapchainInfo.width = (uint32_t)width;
|
||||
swapchainInfo.height = (uint32_t)height;
|
||||
swapchainInfo.faceCount = 1;
|
||||
swapchainInfo.arraySize = 1;
|
||||
swapchainInfo.mipCount = 1;
|
||||
if (!xr_check(instance, xrCreateSwapchain(session, &swapchainInfo, &out->swapchain),
|
||||
"xrCreateSwapchain"))
|
||||
return false;
|
||||
|
||||
uint32_t imageCount = 0;
|
||||
xrEnumerateSwapchainImages(out->swapchain, 0, &imageCount, NULL);
|
||||
if (imageCount > XR_SWAPCHAIN_MAX_IMAGES) {
|
||||
LOGE("XR: swapchain reports %u images, only room for %d", imageCount,
|
||||
XR_SWAPCHAIN_MAX_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[XR_SWAPCHAIN_MAX_IMAGES] = {0};
|
||||
for (uint32_t i = 0; i < imageCount; i++)
|
||||
images[i].type = XR_TYPE_SWAPCHAIN_IMAGE_OPENGL_ES_KHR;
|
||||
if (!xr_check(instance,
|
||||
xrEnumerateSwapchainImages(out->swapchain, imageCount, &imageCount,
|
||||
(XrSwapchainImageBaseHeader *)images),
|
||||
"xrEnumerateSwapchainImages"))
|
||||
return false;
|
||||
out->image_count = imageCount;
|
||||
|
||||
// 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, &out->fbos[i]);
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, out->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);
|
||||
return all_complete;
|
||||
}
|
||||
|
||||
void xr_swapchain_destroy(XrSwapchainState *state) {
|
||||
for (uint32_t i = 0; i < state->image_count; i++) {
|
||||
if (state->fbos[i] != 0)
|
||||
glDeleteFramebuffers(1, &state->fbos[i]);
|
||||
}
|
||||
state->image_count = 0;
|
||||
if (state->swapchain != XR_NULL_HANDLE)
|
||||
xrDestroySwapchain(state->swapchain);
|
||||
state->swapchain = XR_NULL_HANDLE;
|
||||
}
|
||||
|
||||
// Acquires the next image of the given swapchain, waits for the runtime to
|
||||
// finish with it, and binds its framebuffer (sized to exactly fill it) as
|
||||
// the current render target.
|
||||
bool xr_swapchain_acquire(XrInstance instance, XrSwapchainState *state) {
|
||||
uint32_t imageIndex = 0;
|
||||
XrSwapchainImageAcquireInfo acquireInfo = {XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO};
|
||||
if (!xr_check(instance, xrAcquireSwapchainImage(state->swapchain, &acquireInfo, &imageIndex),
|
||||
"xrAcquireSwapchainImage"))
|
||||
return false;
|
||||
|
||||
XrSwapchainImageWaitInfo waitImageInfo = {XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO};
|
||||
waitImageInfo.timeout = XR_INFINITE_DURATION;
|
||||
if (!xr_check(instance, xrWaitSwapchainImage(state->swapchain, &waitImageInfo),
|
||||
"xrWaitSwapchainImage"))
|
||||
return false;
|
||||
|
||||
// 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()) land in the right place.
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, state->fbos[imageIndex]);
|
||||
glViewport(0, 0, state->width, state->height);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void xr_swapchain_release(XrInstance instance, XrSwapchainState *state) {
|
||||
XrSwapchainImageReleaseInfo releaseInfo = {XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO};
|
||||
xr_check(instance, xrReleaseSwapchainImage(state->swapchain, &releaseInfo),
|
||||
"xrReleaseSwapchainImage");
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Generic OpenXR swapchain + per-image GL framebuffer helper, shared by
|
||||
// every quad this app submits - the game quad (xr_session.c) and every
|
||||
// xr_overlay.c instance (menu launcher, keyboard) - since they all need
|
||||
// exactly the same setup, just at their own size/format.
|
||||
#ifndef QUESTSHOCK_XR_SWAPCHAIN_H
|
||||
#define QUESTSHOCK_XR_SWAPCHAIN_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include <EGL/egl.h>
|
||||
#include <GLES3/gl3.h>
|
||||
#include <jni.h>
|
||||
|
||||
#define XR_USE_PLATFORM_ANDROID 1
|
||||
#define XR_USE_GRAPHICS_API_OPENGL_ES 1
|
||||
#include <openxr/openxr.h>
|
||||
#include <openxr/openxr_platform.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
// 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 XR_SWAPCHAIN_MAX_IMAGES 8
|
||||
|
||||
typedef struct {
|
||||
XrSwapchain swapchain;
|
||||
GLuint fbos[XR_SWAPCHAIN_MAX_IMAGES];
|
||||
uint32_t image_count;
|
||||
int width;
|
||||
int height;
|
||||
} XrSwapchainState;
|
||||
|
||||
// Creates the swapchain and wraps each of its images in its own GL
|
||||
// framebuffer, ready to bind and render into directly. instance is only
|
||||
// used to log a human-readable error string on failure.
|
||||
bool xr_swapchain_create(XrInstance instance, XrSession session, int64_t format, int width,
|
||||
int height, XrSwapchainState *out);
|
||||
|
||||
void xr_swapchain_destroy(XrSwapchainState *state);
|
||||
|
||||
// Acquires the next image, waits for the runtime to finish with it, and
|
||||
// binds its framebuffer (sized to exactly fill it) as the current render
|
||||
// target.
|
||||
bool xr_swapchain_acquire(XrInstance instance, XrSwapchainState *state);
|
||||
|
||||
void xr_swapchain_release(XrInstance instance, XrSwapchainState *state);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,586 @@
|
||||
package de.ladkau.questshock;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.util.TypedValue;
|
||||
import android.view.Gravity;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Button;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.libsdl.app.SDLActivity;
|
||||
|
||||
/**
|
||||
* The on-screen keyboard quad (see OverlayPanel for the shared off-screen-
|
||||
* render/touch/cursor plumbing this builds on) - a hand-built, full
|
||||
* US-layout key grid (there's no system IME to borrow once immersive) plus
|
||||
* a text field echoing what's been sent. Every key that reaches the game
|
||||
* (everything but Close/Shift/Ctrl/Alt, which are local UI actions - see
|
||||
* handleKeyDown()/handleKeyUp() and handleCharKeyDown()/handleCharKeyUp())
|
||||
* is driven by that key button's own ACTION_DOWN/ACTION_UP (see
|
||||
* charKeyTouchListener()/controlKeyTouchListener()), which
|
||||
* OverlayPanel.handleTouch() dispatches for exactly as long as the
|
||||
* controller's trigger is physically held while aimed at it - so a key
|
||||
* held on this keyboard behaves like a real key held on a physical one,
|
||||
* not an instant tap. Non-printable keys go through an
|
||||
* SDLActivity.onNativeKeyDown()/onNativeKeyUp() pair, the same one a
|
||||
* physical Bluetooth keyboard's presses already drive; printable keys go
|
||||
* through a synthesized SDL_TEXTINPUT/SDL_KEYUP pair pushed from native
|
||||
* code (see nativeSendPrintableCharDown()/Up() in questshock_native.c) -
|
||||
* except while a Ctrl/Alt "armed" modifier is active, when they go through
|
||||
* the same onNativeKeyDown()/onNativeKeyUp() path instead, so the modifier
|
||||
* carries across (see toggleModifier()). Shift is a purely local layer
|
||||
* toggle (uppercase/symbols vs lowercase/numbers), never itself sent to the
|
||||
* game.
|
||||
*
|
||||
* This is a fully independent overlay quad from MenuOverlay's small
|
||||
* launcher - opened via that launcher's "Keyboard" button
|
||||
* (nativeShowKeyboard()) but otherwise self-contained: once open, the
|
||||
* controller's menu button (which only ever affects the menu launcher, see
|
||||
* xr_input.c) doesn't hide it, so it stays up as a standing input panel
|
||||
* while actually playing rather than a modal you open and close. The only
|
||||
* way to hide it is its own title bar's Close button (buildTitleBar()),
|
||||
* which also doubles as a drag handle for repositioning it (handled
|
||||
* entirely on the native side - see xr_input.c - since it never reaches
|
||||
* this class's own touch dispatch). contentAlpha() renders the whole panel
|
||||
* semi-transparent so whatever's behind it stays visible while it's up.
|
||||
*/
|
||||
public class KeyboardOverlay extends OverlayPanel {
|
||||
// Matches xr_session.c's KEYBOARD_WIDTH/KEYBOARD_HEIGHT swapchain size -
|
||||
// fixed, not tied to any real display metric, since this tree is never
|
||||
// shown in a real window.
|
||||
private static final int WIDTH = 1024;
|
||||
private static final int HEIGHT = 768;
|
||||
|
||||
private static final int TYPED_TEXT_HEIGHT = 100;
|
||||
|
||||
// Height of the title bar (buildTitleBar()) and width of its Close
|
||||
// button - xr_input.c's KEYBOARD_TITLE_BAR_V_FRACTION/
|
||||
// KEYBOARD_CLOSE_BUTTON_U_FRACTION must be kept in sync with these (as
|
||||
// fractions of WIDTH/HEIGHT above), since the drag-handle-vs-Close-
|
||||
// button-vs-rest-of-the-keyboard decision happens entirely on the
|
||||
// native side, before a hit ever reaches this class's own touch
|
||||
// dispatch.
|
||||
private static final int TITLE_BAR_HEIGHT = 90;
|
||||
private static final int CLOSE_BUTTON_WIDTH = 160;
|
||||
|
||||
// ~70% opaque - readable but lets whatever's behind the panel (the game
|
||||
// quad, or empty space) show through while it's left up during play.
|
||||
private static final int PANEL_ALPHA = 180;
|
||||
|
||||
private static KeyboardOverlay instance;
|
||||
|
||||
private TextView typedTextView;
|
||||
private final StringBuilder typedText = new StringBuilder();
|
||||
|
||||
// Title bar chrome text - the only pieces of this panel that get
|
||||
// translated (see Strings.java's own comment on why the actual key
|
||||
// labels don't). Set from MenuOverlay.nativeGetLanguage() at the top of
|
||||
// buildContent() (see OverlayPanel's constructor note on why a field a
|
||||
// subclass's buildContent() depends on can't rely on normal field-
|
||||
// initializer timing) and kept in sync afterward by applyTranslations()
|
||||
// below, called from MenuOverlay's own periodic language-refresh check.
|
||||
private int currentLanguage = Strings.ENGLISH;
|
||||
private TextView titleLabel;
|
||||
private Button closeButton;
|
||||
|
||||
// Shift is a local-only layer toggle (never itself sent to the game -
|
||||
// see updateShiftVisual()); Ctrl/Alt are one-shot "armed" modifiers,
|
||||
// sent to the game the moment they're pressed and released again as
|
||||
// soon as the next key consumes them (see toggleModifier()/
|
||||
// releaseArmedModifiers()) - there's no press-and-hold gesture with a
|
||||
// laser pointer + trigger click, so armed-then-consumed stands in for
|
||||
// holding the key down.
|
||||
private boolean shiftActive = false;
|
||||
private boolean ctrlArmed = false;
|
||||
private boolean altArmed = false;
|
||||
private Button shiftButton;
|
||||
private Button ctrlButton;
|
||||
private Button altButton;
|
||||
|
||||
// Every CharKey created by addRow(), so updateShiftVisual() can relabel
|
||||
// all of them at once when Shift toggles. Assigned (not a field
|
||||
// initializer) at the top of buildContent() - see OverlayPanel's
|
||||
// constructor note on why a field a subclass's buildContent() depends
|
||||
// on can't rely on normal field-initializer timing.
|
||||
private List<CharKey> charKeys;
|
||||
|
||||
// A key whose printed character (and, while unmodified, whose game
|
||||
// input) depends on the Shift layer - covers letters, digits, and
|
||||
// punctuation uniformly (e.g. 'q'/'Q', '1'/'!', '-'/'_'). androidKeyCode
|
||||
// is only used for the Ctrl/Alt-combo path (see handleCharKeyDown()) -
|
||||
// the unmodified path goes through nativeSendPrintableCharDown()/Up()
|
||||
// instead, which doesn't need an Android keycode at all.
|
||||
private static final class CharKey {
|
||||
final char base;
|
||||
final char shifted;
|
||||
final int androidKeyCode;
|
||||
Button button;
|
||||
|
||||
CharKey(char base, char shifted, int androidKeyCode) {
|
||||
this.base = base;
|
||||
this.shifted = shifted;
|
||||
this.androidKeyCode = androidKeyCode;
|
||||
}
|
||||
}
|
||||
|
||||
private static CharKey charKey(char base, char shifted, int androidKeyCode) {
|
||||
return new CharKey(base, shifted, androidKeyCode);
|
||||
}
|
||||
|
||||
// Space is a CharKey like any other (see addRow()), but a literal
|
||||
// space character makes for an unreadable, blank-looking button - keep
|
||||
// showing the word "Space" instead, same as before it became a CharKey.
|
||||
private static String charKeyLabel(CharKey key, boolean shiftActive) {
|
||||
if (key.base == ' ') {
|
||||
return "Space";
|
||||
}
|
||||
return String.valueOf(shiftActive ? key.shifted : key.base);
|
||||
}
|
||||
|
||||
private KeyboardOverlay(Activity activity) {
|
||||
super(activity, WIDTH, HEIGHT);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int contentAlpha() {
|
||||
return PANEL_ALPHA;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected View buildContent() {
|
||||
charKeys = new ArrayList<>();
|
||||
currentLanguage = MenuOverlay.nativeGetLanguage();
|
||||
|
||||
LinearLayout panel = new LinearLayout(activity);
|
||||
panel.setOrientation(LinearLayout.VERTICAL);
|
||||
panel.setBackgroundColor(0xFF202020);
|
||||
|
||||
panel.addView(buildTitleBar(),
|
||||
new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, TITLE_BAR_HEIGHT));
|
||||
|
||||
typedTextView = new TextView(activity);
|
||||
typedTextView.setTextColor(0xFFFFFFFF);
|
||||
typedTextView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 28);
|
||||
typedTextView.setBackgroundColor(0xFF303030);
|
||||
typedTextView.setPadding(20, 20, 20, 20);
|
||||
panel.addView(typedTextView, new LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, TYPED_TEXT_HEIGHT));
|
||||
|
||||
// Rows follow real keyboard geometry where it helps (Tab starting
|
||||
// the QWERTY row, Enter ending the home row, Shift starting the
|
||||
// bottom-letter row, a compact arrow cluster bottom-right) so the
|
||||
// layout reads as a familiar keyboard rather than an arbitrary grid.
|
||||
addRow(panel, "Esc", "F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10",
|
||||
"F11", "F12", "Back");
|
||||
addRow(panel,
|
||||
charKey('`', '~', KeyEvent.KEYCODE_GRAVE),
|
||||
charKey('1', '!', KeyEvent.KEYCODE_1),
|
||||
charKey('2', '@', KeyEvent.KEYCODE_2),
|
||||
charKey('3', '#', KeyEvent.KEYCODE_3),
|
||||
charKey('4', '$', KeyEvent.KEYCODE_4),
|
||||
charKey('5', '%', KeyEvent.KEYCODE_5),
|
||||
charKey('6', '^', KeyEvent.KEYCODE_6),
|
||||
charKey('7', '&', KeyEvent.KEYCODE_7),
|
||||
charKey('8', '*', KeyEvent.KEYCODE_8),
|
||||
charKey('9', '(', KeyEvent.KEYCODE_9),
|
||||
charKey('0', ')', KeyEvent.KEYCODE_0),
|
||||
charKey('-', '_', KeyEvent.KEYCODE_MINUS),
|
||||
charKey('=', '+', KeyEvent.KEYCODE_EQUALS));
|
||||
addRow(panel, "Tab",
|
||||
charKey('q', 'Q', KeyEvent.KEYCODE_Q), charKey('w', 'W', KeyEvent.KEYCODE_W),
|
||||
charKey('e', 'E', KeyEvent.KEYCODE_E), charKey('r', 'R', KeyEvent.KEYCODE_R),
|
||||
charKey('t', 'T', KeyEvent.KEYCODE_T), charKey('y', 'Y', KeyEvent.KEYCODE_Y),
|
||||
charKey('u', 'U', KeyEvent.KEYCODE_U), charKey('i', 'I', KeyEvent.KEYCODE_I),
|
||||
charKey('o', 'O', KeyEvent.KEYCODE_O), charKey('p', 'P', KeyEvent.KEYCODE_P),
|
||||
charKey('[', '{', KeyEvent.KEYCODE_LEFT_BRACKET),
|
||||
charKey(']', '}', KeyEvent.KEYCODE_RIGHT_BRACKET),
|
||||
charKey('\\', '|', KeyEvent.KEYCODE_BACKSLASH));
|
||||
addRow(panel, "Ctrl",
|
||||
charKey('a', 'A', KeyEvent.KEYCODE_A), charKey('s', 'S', KeyEvent.KEYCODE_S),
|
||||
charKey('d', 'D', KeyEvent.KEYCODE_D), charKey('f', 'F', KeyEvent.KEYCODE_F),
|
||||
charKey('g', 'G', KeyEvent.KEYCODE_G), charKey('h', 'H', KeyEvent.KEYCODE_H),
|
||||
charKey('j', 'J', KeyEvent.KEYCODE_J), charKey('k', 'K', KeyEvent.KEYCODE_K),
|
||||
charKey('l', 'L', KeyEvent.KEYCODE_L),
|
||||
charKey(';', ':', KeyEvent.KEYCODE_SEMICOLON),
|
||||
charKey('\'', '"', KeyEvent.KEYCODE_APOSTROPHE),
|
||||
"Enter");
|
||||
addRow(panel, "Shift",
|
||||
charKey('z', 'Z', KeyEvent.KEYCODE_Z), charKey('x', 'X', KeyEvent.KEYCODE_X),
|
||||
charKey('c', 'C', KeyEvent.KEYCODE_C), charKey('v', 'V', KeyEvent.KEYCODE_V),
|
||||
charKey('b', 'B', KeyEvent.KEYCODE_B), charKey('n', 'N', KeyEvent.KEYCODE_N),
|
||||
charKey('m', 'M', KeyEvent.KEYCODE_M),
|
||||
charKey(',', '<', KeyEvent.KEYCODE_COMMA),
|
||||
charKey('.', '>', KeyEvent.KEYCODE_PERIOD),
|
||||
charKey('/', '?', KeyEvent.KEYCODE_SLASH),
|
||||
"Up");
|
||||
addRow(panel, "Alt", charKey(' ', ' ', KeyEvent.KEYCODE_SPACE), "Left", "Down", "Right");
|
||||
return panel;
|
||||
}
|
||||
|
||||
// The title bar: a decorative "drag here" label (not itself
|
||||
// interactive - see the class doc, dragging is handled entirely by
|
||||
// xr_input.c before a hit ever reaches here) plus a real Close button,
|
||||
// which is dispatched as an ordinary click like any other key. Its
|
||||
// height and the Close button's width must stay in sync with
|
||||
// xr_input.c's KEYBOARD_TITLE_BAR_V_FRACTION/
|
||||
// KEYBOARD_CLOSE_BUTTON_U_FRACTION.
|
||||
private LinearLayout buildTitleBar() {
|
||||
LinearLayout bar = new LinearLayout(activity);
|
||||
bar.setOrientation(LinearLayout.HORIZONTAL);
|
||||
// Matches MenuOverlay's own header bar style.
|
||||
bar.setBackgroundColor(0xFF000080);
|
||||
|
||||
titleLabel = new TextView(activity);
|
||||
titleLabel.setText(Strings.get("keyboard_drag_title", currentLanguage));
|
||||
titleLabel.setTextColor(0xFFFFFFFF);
|
||||
titleLabel.setTextSize(TypedValue.COMPLEX_UNIT_SP, 20);
|
||||
titleLabel.setGravity(Gravity.CENTER_VERTICAL);
|
||||
titleLabel.setPadding(24, 0, 0, 0);
|
||||
bar.addView(titleLabel, new LinearLayout.LayoutParams(
|
||||
0, ViewGroup.LayoutParams.MATCH_PARENT, 1f));
|
||||
|
||||
closeButton = new Button(activity);
|
||||
closeButton.setText(Strings.get("close", currentLanguage));
|
||||
closeButton.setOnClickListener(v -> nativeRequestClose());
|
||||
closeButton.setOnTouchListener(CLICK_ON_TOUCH_UP);
|
||||
closeButton.setBackground(buttonBackground());
|
||||
closeButton.setTextColor(buttonTextColors());
|
||||
registerHoverable(closeButton);
|
||||
bar.addView(closeButton,
|
||||
new LinearLayout.LayoutParams(CLOSE_BUTTON_WIDTH, ViewGroup.LayoutParams.MATCH_PARENT));
|
||||
|
||||
return bar;
|
||||
}
|
||||
|
||||
// Each row fills the panel's remaining height evenly (weight 1 on the
|
||||
// row itself). Accepts a mix of String (a fixed-action control key,
|
||||
// handled by handleKeyDown()/handleKeyUp()) and CharKey (a
|
||||
// Shift-sensitive character key, handled by handleCharKeyDown()/
|
||||
// handleCharKeyUp()) per item, so a row can match real keyboard
|
||||
// geometry (e.g. "Tab" followed by a run of CharKeys).
|
||||
// Within a row, CharKeys and most control keys share width equally
|
||||
// except Space (wider, like a real keyboard) and Esc/Back/Enter/Tab/
|
||||
// Shift/Ctrl/Alt (narrower, see keyWeight()).
|
||||
private void addRow(LinearLayout panel, Object... items) {
|
||||
LinearLayout row = new LinearLayout(activity);
|
||||
row.setOrientation(LinearLayout.HORIZONTAL);
|
||||
for (Object item : items) {
|
||||
Button key = new Button(activity);
|
||||
key.setBackground(buttonBackground());
|
||||
key.setTextColor(buttonTextColors());
|
||||
registerHoverable(key);
|
||||
float weight;
|
||||
if (item instanceof CharKey) {
|
||||
CharKey charKey = (CharKey) item;
|
||||
charKey.button = key;
|
||||
key.setText(charKeyLabel(charKey, shiftActive));
|
||||
key.setOnTouchListener(charKeyTouchListener(charKey));
|
||||
charKeys.add(charKey);
|
||||
// Space is a CharKey too (base == shifted == ' '), but keeps
|
||||
// its traditional wide key like the other control keys do.
|
||||
weight = (charKey.base == ' ') ? 4f : 1f;
|
||||
} else {
|
||||
String label = (String) item;
|
||||
key.setText(label);
|
||||
weight = keyWeight(label);
|
||||
// Shift/Ctrl/Alt are local UI toggles with their own
|
||||
// instantaneous press/release semantics (see
|
||||
// toggleModifier()), not a value forwarded to the game for
|
||||
// as long as the key is held - a plain click is enough.
|
||||
// Every other control key reaches the game and needs real
|
||||
// hold-duration semantics (see controlKeyTouchListener()).
|
||||
if (label.equals("Shift") || label.equals("Ctrl") || label.equals("Alt")) {
|
||||
key.setOnClickListener(v -> handleToggleKey(label));
|
||||
key.setOnTouchListener(CLICK_ON_TOUCH_UP);
|
||||
} else {
|
||||
key.setOnTouchListener(controlKeyTouchListener(label));
|
||||
}
|
||||
if (label.equals("Shift")) {
|
||||
shiftButton = key;
|
||||
} else if (label.equals("Ctrl")) {
|
||||
ctrlButton = key;
|
||||
} else if (label.equals("Alt")) {
|
||||
altButton = key;
|
||||
}
|
||||
}
|
||||
row.addView(key, new LinearLayout.LayoutParams(
|
||||
0, ViewGroup.LayoutParams.MATCH_PARENT, weight));
|
||||
}
|
||||
panel.addView(row, new LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f));
|
||||
}
|
||||
|
||||
// Drives a control key (everything controlKeyCode() maps, i.e. every
|
||||
// key in addRow() except CharKeys and the Shift/Ctrl/Alt toggles) from
|
||||
// its button's own touch lifecycle, so it reaches the game for exactly
|
||||
// as long as the controller's trigger is held on it (see the class
|
||||
// doc). ACTION_CANCEL (the ray straying off the button, or another
|
||||
// overlay claiming it - see xr_input.c) releases the key the same as
|
||||
// ACTION_UP, so a key can never be left stuck down.
|
||||
private View.OnTouchListener controlKeyTouchListener(String label) {
|
||||
return (v, event) -> {
|
||||
switch (event.getAction()) {
|
||||
case MotionEvent.ACTION_DOWN:
|
||||
v.setPressed(true);
|
||||
handleKeyDown(label);
|
||||
break;
|
||||
case MotionEvent.ACTION_UP:
|
||||
v.setPressed(false);
|
||||
handleKeyUp(label);
|
||||
break;
|
||||
case MotionEvent.ACTION_CANCEL:
|
||||
v.setPressed(false);
|
||||
handleKeyUp(label);
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
// Same as controlKeyTouchListener() above, for a CharKey.
|
||||
private View.OnTouchListener charKeyTouchListener(CharKey key) {
|
||||
return (v, event) -> {
|
||||
switch (event.getAction()) {
|
||||
case MotionEvent.ACTION_DOWN:
|
||||
v.setPressed(true);
|
||||
handleCharKeyDown(key);
|
||||
break;
|
||||
case MotionEvent.ACTION_UP:
|
||||
v.setPressed(false);
|
||||
handleCharKeyUp(key);
|
||||
break;
|
||||
case MotionEvent.ACTION_CANCEL:
|
||||
v.setPressed(false);
|
||||
handleCharKeyUp(key);
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
private static float keyWeight(String label) {
|
||||
if (label.equals("Back") || label.equals("Esc") || label.equals("Enter")
|
||||
|| label.equals("Tab") || label.equals("Shift") || label.equals("Ctrl")
|
||||
|| label.equals("Alt")) {
|
||||
return 1.5f;
|
||||
}
|
||||
return 1f;
|
||||
}
|
||||
|
||||
// Maps a control key's label to the Android keycode forwarded via
|
||||
// SDLActivity.onNativeKeyDown()/onNativeKeyUp() - the same pair a
|
||||
// physical Bluetooth keyboard's key events already drive (see
|
||||
// SDLActivity.handleKeyEvent()). F-keys are contiguous in
|
||||
// android.view.KeyEvent (KEYCODE_F1..KEYCODE_F12), so "F1".."F12" are
|
||||
// computed rather than listed individually.
|
||||
private static int controlKeyCode(String label) {
|
||||
switch (label) {
|
||||
case "Back": return KeyEvent.KEYCODE_DEL;
|
||||
case "Esc": return KeyEvent.KEYCODE_ESCAPE;
|
||||
case "Enter": return KeyEvent.KEYCODE_ENTER;
|
||||
case "Tab": return KeyEvent.KEYCODE_TAB;
|
||||
case "Up": return KeyEvent.KEYCODE_DPAD_UP;
|
||||
case "Down": return KeyEvent.KEYCODE_DPAD_DOWN;
|
||||
case "Left": return KeyEvent.KEYCODE_DPAD_LEFT;
|
||||
case "Right": return KeyEvent.KEYCODE_DPAD_RIGHT;
|
||||
default:
|
||||
if (label.charAt(0) == 'F') {
|
||||
int n = Integer.parseInt(label.substring(1));
|
||||
return KeyEvent.KEYCODE_F1 + (n - 1);
|
||||
}
|
||||
throw new IllegalArgumentException("no keycode for " + label);
|
||||
}
|
||||
}
|
||||
|
||||
// Shift is a local layer toggle; Ctrl/Alt are one-shot armed modifiers
|
||||
// (see toggleModifier()) - neither is forwarded to the game as a key of
|
||||
// its own, so a plain click (rather than the hold-duration dispatch
|
||||
// every other key below uses) is enough.
|
||||
private void handleToggleKey(String label) {
|
||||
if (label.equals("Shift")) {
|
||||
shiftActive = !shiftActive;
|
||||
updateShiftVisual();
|
||||
return;
|
||||
}
|
||||
toggleModifier(label);
|
||||
}
|
||||
|
||||
// Everything controlKeyCode() maps (Back/Esc/Enter/Tab/arrows/F1-F12)
|
||||
// is non-printable, so it's forwarded as a real Android key event via
|
||||
// that code - down on this key's own ACTION_DOWN, up on ACTION_UP/
|
||||
// CANCEL (see controlKeyTouchListener()), the same down/up split a
|
||||
// physical Bluetooth keyboard's own key events already carry. This all
|
||||
// matters beyond just the visible typedTextView below: the engine's
|
||||
// cutscene skip handler (cutsloop.c's cutscene_key_handler()) only
|
||||
// reacts to Esc/Enter/Space, so those need to reach the game as actual
|
||||
// game input, not just be echoed locally.
|
||||
private void handleKeyDown(String label) {
|
||||
int keyCode = controlKeyCode(label);
|
||||
SDLActivity.onNativeKeyDown(keyCode);
|
||||
|
||||
if (label.equals("Back")) {
|
||||
if (typedText.length() > 0) {
|
||||
typedText.setLength(typedText.length() - 1);
|
||||
}
|
||||
} else {
|
||||
typedText.append('[').append(label).append(']');
|
||||
}
|
||||
typedTextView.setText(typedText.toString());
|
||||
}
|
||||
|
||||
private void handleKeyUp(String label) {
|
||||
SDLActivity.onNativeKeyUp(controlKeyCode(label));
|
||||
releaseArmedModifiers();
|
||||
}
|
||||
|
||||
// A CharKey's printed character depends only on the Shift layer
|
||||
// (updateShiftVisual() keeps its button's label in sync). Which game
|
||||
// input it produces depends on whether a modifier is armed: unmodified,
|
||||
// it goes through nativeSendPrintableCharDown()/Up() below like a
|
||||
// normal typed character; with Ctrl/Alt armed, it instead goes through
|
||||
// the same onNativeKeyDown()/onNativeKeyUp() path handleKeyDown()/
|
||||
// handleKeyUp() use for control keys, since that's the only path that
|
||||
// carries ev.key.keysym.mod through to the engine (sdl_events.c's
|
||||
// pump_events() reads Ctrl/Alt only off that path, never off
|
||||
// SDL_TEXTINPUT).
|
||||
private void handleCharKeyDown(CharKey key) {
|
||||
char c = shiftActive ? key.shifted : key.base;
|
||||
if (ctrlArmed || altArmed) {
|
||||
SDLActivity.onNativeKeyDown(key.androidKeyCode);
|
||||
} else {
|
||||
nativeSendPrintableCharDown(c);
|
||||
}
|
||||
typedText.append(c);
|
||||
typedTextView.setText(typedText.toString());
|
||||
}
|
||||
|
||||
private void handleCharKeyUp(CharKey key) {
|
||||
char c = shiftActive ? key.shifted : key.base;
|
||||
if (ctrlArmed || altArmed) {
|
||||
SDLActivity.onNativeKeyUp(key.androidKeyCode);
|
||||
releaseArmedModifiers();
|
||||
} else {
|
||||
nativeSendPrintableCharUp(c);
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl/Alt are "armed" rather than held: pressing one immediately sends
|
||||
// its keydown (so SDL's own modifier tracking picks it up for whatever
|
||||
// key comes next - see handleCharKeyDown()/handleKeyDown()) and
|
||||
// brackets its label for feedback; pressing the same key again before
|
||||
// it's been used cancels it (sends the matching keyup, un-brackets).
|
||||
// The normal case - actually being consumed by the next key press - is
|
||||
// handled by releaseArmedModifiers() below, not here.
|
||||
private void toggleModifier(String label) {
|
||||
boolean ctrl = label.equals("Ctrl");
|
||||
Button button = ctrl ? ctrlButton : altButton;
|
||||
int keyCode = ctrl ? KeyEvent.KEYCODE_CTRL_LEFT : KeyEvent.KEYCODE_ALT_LEFT;
|
||||
boolean nowArmed = ctrl ? !ctrlArmed : !altArmed;
|
||||
if (ctrl) {
|
||||
ctrlArmed = nowArmed;
|
||||
} else {
|
||||
altArmed = nowArmed;
|
||||
}
|
||||
if (nowArmed) {
|
||||
SDLActivity.onNativeKeyDown(keyCode);
|
||||
} else {
|
||||
SDLActivity.onNativeKeyUp(keyCode);
|
||||
}
|
||||
button.setText(nowArmed ? "[" + label + "]" : label);
|
||||
}
|
||||
|
||||
// Called after any key actually reaches the game (a control key's
|
||||
// handleKeyUp(), or a CharKey's handleCharKeyUp()) so an armed Ctrl/Alt
|
||||
// only ever applies to the very next key, then releases - matching a
|
||||
// real Ctrl/Alt+key combo's keyup once the combo is done.
|
||||
private void releaseArmedModifiers() {
|
||||
if (ctrlArmed) {
|
||||
SDLActivity.onNativeKeyUp(KeyEvent.KEYCODE_CTRL_LEFT);
|
||||
ctrlArmed = false;
|
||||
ctrlButton.setText("Ctrl");
|
||||
}
|
||||
if (altArmed) {
|
||||
SDLActivity.onNativeKeyUp(KeyEvent.KEYCODE_ALT_LEFT);
|
||||
altArmed = false;
|
||||
altButton.setText("Alt");
|
||||
}
|
||||
}
|
||||
|
||||
// Shift never itself reaches the game (see class doc) - it only flips
|
||||
// which character each CharKey shows/sends, including its own label.
|
||||
// Note this means a Ctrl+Shift+key chord won't carry Shift to the
|
||||
// engine (Ctrl/Alt are real forwarded modifier keys, Shift here isn't) -
|
||||
// not worth solving unless it actually comes up.
|
||||
private void updateShiftVisual() {
|
||||
shiftButton.setText(shiftActive ? "[Shift]" : "Shift");
|
||||
for (CharKey key : charKeys) {
|
||||
key.button.setText(charKeyLabel(key, shiftActive));
|
||||
}
|
||||
}
|
||||
|
||||
// Synthesizes the SDL_TEXTINPUT/SDL_KEYUP pair pump_events()
|
||||
// (sdl_events.c) requires for printable characters (see
|
||||
// handleCharKeyDown()/handleCharKeyUp() above), by building and
|
||||
// pushing them directly in native code rather than through Android's
|
||||
// IME (there's no real IME session behind this off-screen,
|
||||
// never-attached grid for that plumbing to hook into).
|
||||
private static native void nativeSendPrintableCharDown(char c);
|
||||
|
||||
private static native void nativeSendPrintableCharUp(char c);
|
||||
|
||||
// Hides this overlay's quad (xr_overlay_set_visible(..., false)) - the
|
||||
// only way to do so, since the controller's menu button doesn't affect
|
||||
// the keyboard (see this class's doc comment and xr_input.c).
|
||||
private static native void nativeRequestClose();
|
||||
|
||||
// Called from MenuOverlay's own periodic header-refresh timer when it
|
||||
// detects the game's language preference changed mid-session (via its
|
||||
// own options menu) - re-labels this panel's title bar chrome to match.
|
||||
// A plain Java call (both classes are in this same package), not JNI -
|
||||
// runOnUiThread() here is defensive/self-documenting more than
|
||||
// strictly needed today, since MenuOverlay's own timer already runs on
|
||||
// the UI thread itself (Activity.runOnUiThread() is a no-op wrapper
|
||||
// when already on that thread).
|
||||
public static void applyTranslations(final int language) {
|
||||
if (instance == null) {
|
||||
return;
|
||||
}
|
||||
instance.activity.runOnUiThread(() -> {
|
||||
instance.currentLanguage = language;
|
||||
instance.titleLabel.setText(Strings.get("keyboard_drag_title", language));
|
||||
instance.closeButton.setText(Strings.get("close", language));
|
||||
instance.redrawContent();
|
||||
});
|
||||
}
|
||||
|
||||
// Called from xr_overlay.c's xr_overlay_init_jni(), on the render
|
||||
// thread, right after it resolves this class via the activity's own
|
||||
// ClassLoader (plain FindClass() can't see app classes from a thread
|
||||
// that was attached to the JVM rather than spawned from Java).
|
||||
public static void nativeInit(Activity activity) {
|
||||
if (instance == null) {
|
||||
instance = new KeyboardOverlay(activity);
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[] nativeTakePixelsIfDirty() {
|
||||
return instance == null ? null : instance.takePixelsIfDirty();
|
||||
}
|
||||
|
||||
public static void nativeDispatchTouch(final float u, final float v, final boolean down) {
|
||||
if (instance != null) {
|
||||
instance.dispatchTouch(u, v, down);
|
||||
}
|
||||
}
|
||||
|
||||
public static void nativeUpdateCursor(final float u, final float v, final boolean visible) {
|
||||
if (instance != null) {
|
||||
instance.dispatchUpdateCursor(u, v, visible);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,132 +1,668 @@
|
||||
package de.ladkau.questshock;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.Canvas;
|
||||
import android.os.SystemClock;
|
||||
import android.util.Log;
|
||||
import android.app.ActivityManager;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.graphics.drawable.ColorDrawable;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.graphics.drawable.StateListDrawable;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.TypedValue;
|
||||
import android.view.Gravity;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Button;
|
||||
import android.widget.FrameLayout;
|
||||
import java.nio.ByteBuffer;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* An off-screen, never-attached-to-a-window View tree for questshock's
|
||||
* OpenXR menu quad (see android/app/src/main/cpp/xr_menu.c, which owns the
|
||||
* quad's swapchain and drives this class entirely via JNI - construction,
|
||||
* touch input, and pixel readback). Rendering to a Bitmap and dispatching
|
||||
* synthetic MotionEvents into an unattached hierarchy both work the same
|
||||
* way they would for an attached View - draw(Canvas)/dispatchTouchEvent()
|
||||
* don't require a ViewRootImpl/window, just a measured+laid-out tree.
|
||||
* The main menu launcher quad (see OverlayPanel for the shared off-screen-
|
||||
* render/touch/cursor plumbing this builds on). Toggled by the controller's
|
||||
* menu button (see xr_input.c); none of its buttons ever touch this panel's
|
||||
* own visibility, so it can stay open alongside whatever they open/do.
|
||||
*
|
||||
* Layout, top to bottom (see res/sketch_main_menu.svg): a header (title +
|
||||
* version, FPS/frame-time stats, refreshed on a periodic timer), a content
|
||||
* area holding one tab's content at a time, and a bottom tab bar. Tab 1
|
||||
* ("Main Menu") is the original "Keyboard"/"Save Configuration"/"Load
|
||||
* Configuration" row plus a Language row, with a dialog view (shown instead
|
||||
* of that content) doubling as both the restart-confirmation prompt (see
|
||||
* CONFIG.md and qs_config.c's *_REQUIRES_RESTART comments) and a plain
|
||||
* informational/warning popup (showMessageDialog() below).
|
||||
* Tab 2 ("Video Options") has live-appliable controls for the config file's
|
||||
* video_options group, mirroring the same buttons' selection to whatever's
|
||||
* actually active (queried fresh whenever the tab is shown, and on the same
|
||||
* periodic timer as the header stats while it's the visible tab, so e.g.
|
||||
* pressing Ctrl+G while looking at this tab is reflected here too).
|
||||
*
|
||||
* "Keyboard" opens the (fully independent) keyboard overlay quad via
|
||||
* nativeShowKeyboard() - see KeyboardOverlay for the keyboard itself, which
|
||||
* this class also pushes language changes to (see refreshStats()).
|
||||
*/
|
||||
public class MenuOverlay {
|
||||
private static final String TAG = "QuestShock";
|
||||
public class MenuOverlay extends OverlayPanel {
|
||||
// Grown from the original 512x256 (still just a single "Keyboard"
|
||||
// button back then) to fit the header/tabs/Video Options content added
|
||||
// since, then again to fit the stats ribbon below the tab bar - see
|
||||
// xr_session.c's matching MENU_WIDTH/MENU_HEIGHT and xr_input.c's
|
||||
// matching MENU_TITLE_BAR_V_FRACTION.
|
||||
private static final int WIDTH = 768;
|
||||
private static final int HEIGHT = 552;
|
||||
private static final int HEADER_HEIGHT = 56;
|
||||
private static final int TAB_BAR_HEIGHT = 48;
|
||||
private static final int STATS_BAR_HEIGHT = 40;
|
||||
private static final long STATS_REFRESH_MS = 500;
|
||||
|
||||
// Matches xr_menu.c's MENU_WIDTH/MENU_HEIGHT swapchain size - fixed,
|
||||
// not tied to any real display metric, since this tree is never shown
|
||||
// in a real window.
|
||||
static final int WIDTH = 1024;
|
||||
static final int HEIGHT = 768;
|
||||
private static final String[] RESOLUTION_LABELS = {"320x200", "320x400", "640x400", "640x480", "1024x768"};
|
||||
// Order must match qs_config.c's kLanguages[]/gShockPrefs.goLanguage's
|
||||
// own numbering (Prefs.h: 0 English/1 French/2 German) - also the keys
|
||||
// this same language's own Strings.java translation is filed under.
|
||||
private static final String[] LANGUAGE_KEYS = {"english", "french", "german"};
|
||||
|
||||
private static MenuOverlay instance;
|
||||
|
||||
private final Activity activity;
|
||||
private final FrameLayout root;
|
||||
private final Button keyboardButton;
|
||||
private final Bitmap bitmap;
|
||||
private final Canvas canvas;
|
||||
// Kept so relayoutAndRedraw() (see its own comment) can re-measure/
|
||||
// re-layout the same View object OverlayPanel's own private "root"
|
||||
// field also points at - toggling a child's visibility on this
|
||||
// permanently-unattached tree doesn't get a layout pass for free the
|
||||
// way it would on a real window (nothing ever schedules one), so this
|
||||
// class has to force one itself on every toggle.
|
||||
private View root;
|
||||
|
||||
private final Object pixelLock = new Object();
|
||||
private byte[] pendingPixels;
|
||||
// Header
|
||||
private TextView titleView;
|
||||
private TextView statsView;
|
||||
|
||||
// Content area (tabs) - only one of mainMenuTab/videoOptionsTab is
|
||||
// VISIBLE at a time; currentTab tracks which.
|
||||
private View mainMenuTab;
|
||||
private View videoOptionsTab;
|
||||
private View currentTab;
|
||||
private Button mainMenuTabButton;
|
||||
private Button videoOptionsTabButton;
|
||||
|
||||
// Main Menu tab's own two states (only one VISIBLE at a time, within
|
||||
// mainMenuTab) - mainMenuContent is the normal state (button row +
|
||||
// Language row); restartDialog replaces it when Load Configuration
|
||||
// finds a restart-required change.
|
||||
private LinearLayout mainMenuContent;
|
||||
private LinearLayout buttonRow;
|
||||
private LinearLayout restartDialog;
|
||||
private TextView restartMessageView;
|
||||
private Button keyboardButton;
|
||||
private Button saveButton;
|
||||
private Button loadButton;
|
||||
private Button resetPositionButton;
|
||||
private Button dismissButton;
|
||||
private Button restartNowButton;
|
||||
|
||||
// Main Menu tab's Language row
|
||||
private TextView languageLabel;
|
||||
private Button[] languageButtons;
|
||||
|
||||
// Video Options tab controls
|
||||
private TextView rendererLabel;
|
||||
private TextView resolutionLabel;
|
||||
private Button softwareButton;
|
||||
private Button openglButton;
|
||||
private Button[] resolutionButtons;
|
||||
|
||||
private int currentLanguage = Strings.ENGLISH;
|
||||
|
||||
private final Handler handler = new Handler(Looper.getMainLooper());
|
||||
private final Runnable statsRefresh = new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
refreshStats();
|
||||
handler.postDelayed(this, STATS_REFRESH_MS);
|
||||
}
|
||||
};
|
||||
|
||||
private MenuOverlay(Activity activity) {
|
||||
this.activity = activity;
|
||||
super(activity, WIDTH, HEIGHT);
|
||||
handler.postDelayed(statsRefresh, STATS_REFRESH_MS);
|
||||
}
|
||||
|
||||
root = new FrameLayout(activity);
|
||||
root.setBackgroundColor(0xFF202020);
|
||||
@Override
|
||||
protected View buildContent() {
|
||||
// Read before anything below uses it - buildContent() runs from
|
||||
// OverlayPanel's own constructor, before this subclass's field
|
||||
// initializers have (see OverlayPanel's contentAlpha() comment for
|
||||
// the general caveat this same construction order creates).
|
||||
currentLanguage = nativeGetLanguage();
|
||||
|
||||
keyboardButton = new Button(activity);
|
||||
keyboardButton.setText("Keyboard");
|
||||
keyboardButton.setOnClickListener(v -> Log.i(TAG, "MenuOverlay: Keyboard button clicked"));
|
||||
FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(420, 140);
|
||||
lp.gravity = Gravity.CENTER;
|
||||
root.addView(keyboardButton, lp);
|
||||
LinearLayout panel = new LinearLayout(activity);
|
||||
panel.setOrientation(LinearLayout.VERTICAL);
|
||||
panel.setBackgroundColor(0xFF202020);
|
||||
panel.addView(buildHeader(), new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT, HEADER_HEIGHT));
|
||||
|
||||
bitmap = Bitmap.createBitmap(WIDTH, HEIGHT, Bitmap.Config.ARGB_8888);
|
||||
canvas = new Canvas(bitmap);
|
||||
FrameLayout content = new FrameLayout(activity);
|
||||
mainMenuTab = buildMainMenuTab();
|
||||
videoOptionsTab = buildVideoOptionsTab();
|
||||
content.addView(mainMenuTab, new FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
|
||||
content.addView(videoOptionsTab, new FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
|
||||
videoOptionsTab.setVisibility(View.GONE);
|
||||
currentTab = mainMenuTab;
|
||||
panel.addView(content, new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f));
|
||||
|
||||
panel.addView(buildTabBar(), new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT, TAB_BAR_HEIGHT));
|
||||
|
||||
panel.addView(buildStatsBar(), new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT, STATS_BAR_HEIGHT));
|
||||
|
||||
root = panel;
|
||||
return panel;
|
||||
}
|
||||
|
||||
private View buildHeader() {
|
||||
LinearLayout header = new LinearLayout(activity);
|
||||
header.setOrientation(LinearLayout.HORIZONTAL);
|
||||
header.setBackgroundColor(0xFF000080);
|
||||
header.setGravity(Gravity.CENTER_VERTICAL);
|
||||
header.setPadding(16, 0, 16, 0);
|
||||
|
||||
titleView = new TextView(activity);
|
||||
titleView.setTextColor(0xFFFFFFFF);
|
||||
titleView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 20);
|
||||
titleView.setText("Questshock (" + BuildConfig.VERSION_NAME + ")");
|
||||
header.addView(titleView,
|
||||
new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f));
|
||||
|
||||
return header;
|
||||
}
|
||||
|
||||
// A separate ribbon below the tab bar (rather than sharing the header
|
||||
// row with the title, as before) - the FPS/frame-time/CPU/GPU/memory
|
||||
// text got too long for the title bar once CPU/GPU/memory were added,
|
||||
// crowding the title.
|
||||
private View buildStatsBar() {
|
||||
LinearLayout bar = new LinearLayout(activity);
|
||||
bar.setOrientation(LinearLayout.HORIZONTAL);
|
||||
bar.setBackgroundColor(0xFF000080);
|
||||
bar.setGravity(Gravity.CENTER);
|
||||
|
||||
statsView = new TextView(activity);
|
||||
statsView.setTextColor(0xFFFFFFFF);
|
||||
statsView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18);
|
||||
bar.addView(statsView, new LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
|
||||
|
||||
return bar;
|
||||
}
|
||||
|
||||
private View buildMainMenuTab() {
|
||||
FrameLayout tab = new FrameLayout(activity);
|
||||
|
||||
mainMenuContent = new LinearLayout(activity);
|
||||
mainMenuContent.setOrientation(LinearLayout.VERTICAL);
|
||||
mainMenuContent.setGravity(Gravity.CENTER);
|
||||
|
||||
buttonRow = new LinearLayout(activity);
|
||||
buttonRow.setOrientation(LinearLayout.HORIZONTAL);
|
||||
buttonRow.setGravity(Gravity.CENTER);
|
||||
keyboardButton = addRowButton(buttonRow, Strings.get("keyboard", currentLanguage),
|
||||
v -> nativeShowKeyboard());
|
||||
saveButton = addRowButton(buttonRow, Strings.get("save_configuration", currentLanguage),
|
||||
v -> nativeRequestSaveConfig());
|
||||
loadButton = addRowButton(buttonRow, Strings.get("load_configuration", currentLanguage),
|
||||
v -> nativeRequestLoadConfig());
|
||||
resetPositionButton = addRowButton(buttonRow, Strings.get("reset_position", currentLanguage),
|
||||
v -> nativeRequestResetPosition());
|
||||
mainMenuContent.addView(buttonRow, new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT));
|
||||
|
||||
languageLabel = new TextView(activity);
|
||||
languageLabel.setTextColor(0xFFFFFFFF);
|
||||
languageLabel.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18);
|
||||
languageLabel.setGravity(Gravity.CENTER);
|
||||
languageLabel.setPadding(0, 24, 0, 0);
|
||||
languageLabel.setText(Strings.get("language", currentLanguage));
|
||||
mainMenuContent.addView(languageLabel);
|
||||
|
||||
LinearLayout languageRow = new LinearLayout(activity);
|
||||
languageRow.setOrientation(LinearLayout.HORIZONTAL);
|
||||
languageButtons = new Button[LANGUAGE_KEYS.length];
|
||||
for (int i = 0; i < LANGUAGE_KEYS.length; i++) {
|
||||
final int index = i;
|
||||
languageButtons[i] = addSelectableButton(languageRow, Strings.get(LANGUAGE_KEYS[i], currentLanguage),
|
||||
v -> nativeRequestSetLanguage(index));
|
||||
}
|
||||
mainMenuContent.addView(languageRow, new LinearLayout.LayoutParams(
|
||||
600, LinearLayout.LayoutParams.WRAP_CONTENT));
|
||||
|
||||
FrameLayout.LayoutParams mainMenuContentParams = new FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT);
|
||||
mainMenuContentParams.gravity = Gravity.CENTER;
|
||||
tab.addView(mainMenuContent, mainMenuContentParams);
|
||||
|
||||
restartMessageView = new TextView(activity);
|
||||
restartMessageView.setTextColor(0xFFFFFFFF);
|
||||
restartMessageView.setGravity(Gravity.CENTER);
|
||||
restartMessageView.setPadding(16, 8, 16, 16);
|
||||
|
||||
LinearLayout restartButtonRow = new LinearLayout(activity);
|
||||
restartButtonRow.setOrientation(LinearLayout.HORIZONTAL);
|
||||
restartButtonRow.setGravity(Gravity.CENTER);
|
||||
dismissButton = addRowButton(restartButtonRow, Strings.get("dismiss", currentLanguage),
|
||||
v -> showButtonRow());
|
||||
restartNowButton = addRowButton(restartButtonRow, Strings.get("restart_now", currentLanguage),
|
||||
v -> restartApp());
|
||||
|
||||
restartDialog = new LinearLayout(activity);
|
||||
restartDialog.setOrientation(LinearLayout.VERTICAL);
|
||||
restartDialog.setGravity(Gravity.CENTER);
|
||||
restartDialog.setVisibility(View.GONE);
|
||||
restartDialog.addView(restartMessageView,
|
||||
new LinearLayout.LayoutParams(WIDTH - 32, LinearLayout.LayoutParams.WRAP_CONTENT));
|
||||
restartDialog.addView(restartButtonRow);
|
||||
FrameLayout.LayoutParams restartDialogParams = new FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT);
|
||||
tab.addView(restartDialog, restartDialogParams);
|
||||
|
||||
return tab;
|
||||
}
|
||||
|
||||
private View buildVideoOptionsTab() {
|
||||
LinearLayout tab = new LinearLayout(activity);
|
||||
tab.setOrientation(LinearLayout.VERTICAL);
|
||||
tab.setPadding(24, 16, 24, 16);
|
||||
|
||||
rendererLabel = new TextView(activity);
|
||||
rendererLabel.setTextColor(0xFFFFFFFF);
|
||||
rendererLabel.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18);
|
||||
rendererLabel.setText(Strings.get("renderer", currentLanguage));
|
||||
tab.addView(rendererLabel);
|
||||
|
||||
LinearLayout rendererRow = new LinearLayout(activity);
|
||||
rendererRow.setOrientation(LinearLayout.HORIZONTAL);
|
||||
softwareButton = addSelectableButton(rendererRow, Strings.get("software", currentLanguage),
|
||||
v -> nativeRequestSetRenderer(false));
|
||||
// "OpenGL" is a proper/brand name - not translated, see Strings.java.
|
||||
openglButton = addSelectableButton(rendererRow, "OpenGL", v -> nativeRequestSetRenderer(true));
|
||||
tab.addView(rendererRow);
|
||||
|
||||
resolutionLabel = new TextView(activity);
|
||||
resolutionLabel.setTextColor(0xFFFFFFFF);
|
||||
resolutionLabel.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18);
|
||||
resolutionLabel.setPadding(0, 16, 0, 0);
|
||||
resolutionLabel.setText(Strings.get("resolution", currentLanguage));
|
||||
tab.addView(resolutionLabel);
|
||||
|
||||
LinearLayout resolutionRow = new LinearLayout(activity);
|
||||
resolutionRow.setOrientation(LinearLayout.HORIZONTAL);
|
||||
resolutionButtons = new Button[RESOLUTION_LABELS.length];
|
||||
for (int i = 0; i < RESOLUTION_LABELS.length; i++) {
|
||||
final int index = i;
|
||||
resolutionButtons[i] = addSelectableButton(resolutionRow, RESOLUTION_LABELS[i],
|
||||
v -> nativeRequestSetResolution(index));
|
||||
}
|
||||
tab.addView(resolutionRow);
|
||||
|
||||
return tab;
|
||||
}
|
||||
|
||||
private View buildTabBar() {
|
||||
LinearLayout tabBar = new LinearLayout(activity);
|
||||
tabBar.setOrientation(LinearLayout.HORIZONTAL);
|
||||
tabBar.setBackgroundColor(0xFF000080);
|
||||
|
||||
// Selectable background (like the Video Options tab's own Renderer/
|
||||
// Resolution buttons) so the currently-active tab stays visibly
|
||||
// highlighted, not just hovered/pressed - see the setSelected()
|
||||
// calls in switchTab() below.
|
||||
mainMenuTabButton = new Button(activity);
|
||||
mainMenuTabButton.setText(Strings.get("main_menu", currentLanguage));
|
||||
mainMenuTabButton.setBackground(selectableButtonBackground());
|
||||
mainMenuTabButton.setTextColor(buttonTextColors());
|
||||
mainMenuTabButton.setOnClickListener(v -> switchTab(mainMenuTab));
|
||||
mainMenuTabButton.setOnTouchListener(CLICK_ON_TOUCH_UP);
|
||||
mainMenuTabButton.setSelected(true);
|
||||
registerHoverable(mainMenuTabButton);
|
||||
tabBar.addView(mainMenuTabButton,
|
||||
new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f));
|
||||
|
||||
View separator = new View(activity);
|
||||
separator.setBackgroundColor(0xFFFFFFFF);
|
||||
tabBar.addView(separator, new LinearLayout.LayoutParams(2, ViewGroup.LayoutParams.MATCH_PARENT));
|
||||
|
||||
videoOptionsTabButton = new Button(activity);
|
||||
videoOptionsTabButton.setText(Strings.get("video_options", currentLanguage));
|
||||
videoOptionsTabButton.setBackground(selectableButtonBackground());
|
||||
videoOptionsTabButton.setTextColor(buttonTextColors());
|
||||
videoOptionsTabButton.setOnClickListener(v -> switchTab(videoOptionsTab));
|
||||
videoOptionsTabButton.setOnTouchListener(CLICK_ON_TOUCH_UP);
|
||||
registerHoverable(videoOptionsTabButton);
|
||||
tabBar.addView(videoOptionsTabButton,
|
||||
new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f));
|
||||
|
||||
return tabBar;
|
||||
}
|
||||
|
||||
private void switchTab(View tab) {
|
||||
if (tab == currentTab) {
|
||||
return;
|
||||
}
|
||||
currentTab.setVisibility(View.GONE);
|
||||
tab.setVisibility(View.VISIBLE);
|
||||
currentTab = tab;
|
||||
mainMenuTabButton.setSelected(tab == mainMenuTab);
|
||||
videoOptionsTabButton.setSelected(tab == videoOptionsTab);
|
||||
resetHover();
|
||||
if (tab == videoOptionsTab) {
|
||||
refreshVideoOptionsSelection();
|
||||
} else if (tab == mainMenuTab) {
|
||||
refreshLanguageSelection();
|
||||
}
|
||||
relayoutAndRedraw();
|
||||
}
|
||||
|
||||
// Forces a fresh measure/layout pass over the whole tree before
|
||||
// redrawing - required after any visibility toggle (switchTab()/
|
||||
// showButtonRow()/showRestartDialog() below), since a hidden child
|
||||
// starts/becomes GONE and most ViewGroups skip measuring/laying out
|
||||
// GONE children entirely; without this, the first time it's shown it
|
||||
// would draw with a stale/zero layout from having never been measured.
|
||||
// Mirrors the exact measure()/layout() calls OverlayPanel's own
|
||||
// constructor already makes once, up front, on the same View object.
|
||||
private void relayoutAndRedraw() {
|
||||
int widthSpec = View.MeasureSpec.makeMeasureSpec(WIDTH, View.MeasureSpec.EXACTLY);
|
||||
int heightSpec = View.MeasureSpec.makeMeasureSpec(HEIGHT, View.MeasureSpec.EXACTLY);
|
||||
root.measure(widthSpec, heightSpec);
|
||||
root.layout(0, 0, WIDTH, HEIGHT);
|
||||
|
||||
forceRedraw();
|
||||
redrawContent();
|
||||
}
|
||||
|
||||
// Called from xr_menu.c's xr_menu_init(), on the render thread, right
|
||||
// after it resolves this class via the activity's own ClassLoader
|
||||
// (plain FindClass() can't see app classes from a thread that was
|
||||
// attached to the JVM rather than spawned from Java - see that file's
|
||||
// xr_menu_find_class()).
|
||||
private Button addRowButton(LinearLayout row, String text, View.OnClickListener onClick) {
|
||||
Button button = new Button(activity);
|
||||
button.setText(text);
|
||||
button.setOnClickListener(onClick);
|
||||
button.setOnTouchListener(CLICK_ON_TOUCH_UP);
|
||||
button.setBackground(buttonBackground());
|
||||
button.setTextColor(buttonTextColors());
|
||||
registerHoverable(button);
|
||||
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(0, 100, 1f);
|
||||
lp.setMargins(8, 8, 8, 8);
|
||||
row.addView(button, lp);
|
||||
return button;
|
||||
}
|
||||
|
||||
// Like addRowButton(), but with a background that also has a distinct
|
||||
// (persistent, not just transient hover/press) "selected" appearance -
|
||||
// used for the Video Options tab's own controls, where exactly one
|
||||
// button per row always represents the currently-active value (see
|
||||
// refreshVideoOptionsSelection()). Same normal/hovered/pressed colors
|
||||
// as OverlayPanel.buttonBackground(), plus one more state.
|
||||
private static Drawable selectableButtonBackground() {
|
||||
StateListDrawable drawable = new StateListDrawable();
|
||||
drawable.addState(new int[]{android.R.attr.state_pressed}, new ColorDrawable(PRESSED_COLOR));
|
||||
drawable.addState(new int[]{android.R.attr.state_hovered}, new ColorDrawable(HOVERED_COLOR));
|
||||
drawable.addState(new int[]{android.R.attr.state_selected}, new ColorDrawable(0xFF2A6E2A));
|
||||
drawable.addState(new int[]{}, new ColorDrawable(NORMAL_COLOR));
|
||||
return drawable;
|
||||
}
|
||||
|
||||
private Button addSelectableButton(LinearLayout row, String text, View.OnClickListener onClick) {
|
||||
Button button = new Button(activity);
|
||||
button.setText(text);
|
||||
button.setOnClickListener(onClick);
|
||||
button.setOnTouchListener(CLICK_ON_TOUCH_UP);
|
||||
button.setBackground(selectableButtonBackground());
|
||||
button.setTextColor(buttonTextColors());
|
||||
registerHoverable(button);
|
||||
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(0, 90, 1f);
|
||||
lp.setMargins(4, 8, 4, 4);
|
||||
row.addView(button, lp);
|
||||
return button;
|
||||
}
|
||||
|
||||
// Reflects the currently-active renderer/resolution (queried fresh via
|
||||
// the native getters, so it stays correct even if changed some other
|
||||
// way, e.g. the Ctrl+G hotkey) onto the Video Options tab's own
|
||||
// buttons' persistent "selected" state - called when the tab is shown
|
||||
// (switchTab()) and, while it's the visible tab, on the same periodic
|
||||
// timer as the header stats (refreshStats()).
|
||||
private void refreshVideoOptionsSelection() {
|
||||
boolean opengl = nativeGetRendererIsOpenGl();
|
||||
softwareButton.setSelected(!opengl);
|
||||
openglButton.setSelected(opengl);
|
||||
|
||||
int index = nativeGetResolutionIndex();
|
||||
for (int i = 0; i < resolutionButtons.length; i++) {
|
||||
resolutionButtons[i].setSelected(i == index);
|
||||
}
|
||||
}
|
||||
|
||||
// Same reasoning as refreshVideoOptionsSelection() above, for the Main
|
||||
// Menu tab's Language row - nativeGetLanguage() is the same getter
|
||||
// refreshStats() already polls to pick the menu/keyboard chrome text's
|
||||
// own language, so this reflects a language change made any way (our
|
||||
// own Language row, the game's own options menu, or Load Configuration).
|
||||
private void refreshLanguageSelection() {
|
||||
int language = nativeGetLanguage();
|
||||
for (int i = 0; i < languageButtons.length; i++) {
|
||||
languageButtons[i].setSelected(i == language);
|
||||
}
|
||||
}
|
||||
|
||||
// Polled every STATS_REFRESH_MS while this instance exists (started
|
||||
// from the constructor) rather than only while the menu is actually
|
||||
// visible - simpler than plumbing a new native->Java "menu became
|
||||
// visible" callback, and cheap enough not to matter (a couple of JNI
|
||||
// calls, a text update, and a redraw of a panel that isn't even being
|
||||
// sampled into the XR compositor while hidden).
|
||||
private void refreshStats() {
|
||||
float fps = nativeGetFps();
|
||||
float frameMs = nativeGetFrameTimeMs();
|
||||
// -1 (xr_session.h's sentinel) means the XR_META_performance_metrics
|
||||
// extension wasn't available/didn't expose this counter on this
|
||||
// runtime - omit it rather than showing a nonsense "-1%".
|
||||
float cpuUtilization = nativeGetCpuUtilization();
|
||||
float gpuUtilization = nativeGetGpuUtilization();
|
||||
StringBuilder stats = new StringBuilder();
|
||||
stats.append(String.format(Locale.US, "FPS: %.0f | %.1fms", fps, frameMs));
|
||||
if (cpuUtilization >= 0.0f) {
|
||||
stats.append(String.format(Locale.US, " | CPU: %.0f%%", cpuUtilization));
|
||||
}
|
||||
if (gpuUtilization >= 0.0f) {
|
||||
stats.append(String.format(Locale.US, " | GPU: %.0f%%", gpuUtilization));
|
||||
}
|
||||
stats.append(String.format(Locale.US, " | Free: %dMB", freeMemoryMb()));
|
||||
statsView.setText(stats.toString());
|
||||
|
||||
int language = nativeGetLanguage();
|
||||
if (language != currentLanguage) {
|
||||
currentLanguage = language;
|
||||
applyTranslations();
|
||||
KeyboardOverlay.applyTranslations(language);
|
||||
}
|
||||
|
||||
if (currentTab == videoOptionsTab) {
|
||||
refreshVideoOptionsSelection();
|
||||
} else if (currentTab == mainMenuTab) {
|
||||
refreshLanguageSelection();
|
||||
}
|
||||
|
||||
// The stats text's width changes every tick (digit count, "ms"
|
||||
// widths, ...) - this view tree is never attached to a real window,
|
||||
// so nothing re-measures/lays it out on its own the way a real
|
||||
// window's layout pass would; see relayoutAndRedraw()'s own comment
|
||||
// for the GONE-child variant of the same underlying issue.
|
||||
relayoutAndRedraw();
|
||||
}
|
||||
|
||||
// System-wide available RAM, in megabytes - a plain Android API query
|
||||
// (no native/XR involvement, unlike the CPU/GPU stats above), matching
|
||||
// the "Free=NNNNMB" figure in the VrApi performance-HUD logcat line this
|
||||
// header's stats display was modeled on.
|
||||
private long freeMemoryMb() {
|
||||
ActivityManager activityManager =
|
||||
(ActivityManager) activity.getSystemService(Context.ACTIVITY_SERVICE);
|
||||
ActivityManager.MemoryInfo memoryInfo = new ActivityManager.MemoryInfo();
|
||||
activityManager.getMemoryInfo(memoryInfo);
|
||||
return memoryInfo.availMem / (1024 * 1024);
|
||||
}
|
||||
|
||||
// Re-labels every piece of chrome text this class owns - called when
|
||||
// refreshStats() (above) detects the game's language preference
|
||||
// changed mid-session (via its own options menu).
|
||||
private void applyTranslations() {
|
||||
keyboardButton.setText(Strings.get("keyboard", currentLanguage));
|
||||
saveButton.setText(Strings.get("save_configuration", currentLanguage));
|
||||
loadButton.setText(Strings.get("load_configuration", currentLanguage));
|
||||
resetPositionButton.setText(Strings.get("reset_position", currentLanguage));
|
||||
dismissButton.setText(Strings.get("dismiss", currentLanguage));
|
||||
restartNowButton.setText(Strings.get("restart_now", currentLanguage));
|
||||
mainMenuTabButton.setText(Strings.get("main_menu", currentLanguage));
|
||||
videoOptionsTabButton.setText(Strings.get("video_options", currentLanguage));
|
||||
rendererLabel.setText(Strings.get("renderer", currentLanguage));
|
||||
resolutionLabel.setText(Strings.get("resolution", currentLanguage));
|
||||
softwareButton.setText(Strings.get("software", currentLanguage));
|
||||
// openglButton's "OpenGL" label is never translated - see
|
||||
// buildVideoOptionsTab()'s own comment on it.
|
||||
languageLabel.setText(Strings.get("language", currentLanguage));
|
||||
for (int i = 0; i < languageButtons.length; i++) {
|
||||
languageButtons[i].setText(Strings.get(LANGUAGE_KEYS[i], currentLanguage));
|
||||
}
|
||||
}
|
||||
|
||||
// "Dismiss" on the restart dialog - the live-appliable subset was
|
||||
// already applied (on the SDL thread, before this dialog was ever
|
||||
// shown - see qs_config.c's qs_config_handle_event()), so this is
|
||||
// purely a local UI action, no native call needed.
|
||||
private void showButtonRow() {
|
||||
mainMenuContent.setVisibility(View.VISIBLE);
|
||||
restartDialog.setVisibility(View.GONE);
|
||||
resetHover();
|
||||
relayoutAndRedraw();
|
||||
}
|
||||
|
||||
// "Restart Now" on the restart dialog - relaunches the whole app via the
|
||||
// standard Android self-relaunch pattern (kill this process, start a
|
||||
// fresh one from the launcher intent) rather than trying to tear down
|
||||
// and re-init the engine's own OpenXR/session/swapchain/audio state in
|
||||
// place. The fresh process re-runs qs_config_load_startup() from
|
||||
// scratch, applying every group unconditionally. Purely a
|
||||
// Java/Activity/process action - no native call needed.
|
||||
private void restartApp() {
|
||||
Intent intent = activity.getPackageManager().getLaunchIntentForPackage(activity.getPackageName());
|
||||
if (intent == null) {
|
||||
return;
|
||||
}
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
activity.startActivity(intent);
|
||||
Runtime.getRuntime().exit(0);
|
||||
}
|
||||
|
||||
// Opens the keyboard overlay quad (questshock_native.c ->
|
||||
// xr_overlay_set_visible(xr_session_get_keyboard_overlay(), true)) -
|
||||
// see KeyboardOverlay for the panel itself and its own Close button,
|
||||
// the only way to hide it again.
|
||||
private static native void nativeShowKeyboard();
|
||||
|
||||
// "Save Configuration"/"Load Configuration" - see qs_config.h for why
|
||||
// these just enqueue work for the engine's own thread rather than
|
||||
// touching engine state directly from here (the UI thread).
|
||||
private static native void nativeRequestSaveConfig();
|
||||
private static native void nativeRequestLoadConfig();
|
||||
|
||||
// Video Options tab's controls - same enqueue-only reasoning as above.
|
||||
private static native void nativeRequestSetRenderer(boolean opengl);
|
||||
private static native void nativeRequestSetResolution(int resolutionIndex);
|
||||
// Main Menu tab's Language row - same enqueue-only reasoning as above.
|
||||
private static native void nativeRequestSetLanguage(int languageIndex);
|
||||
// Main Menu tab's "Reset Position" button - same enqueue-only reasoning
|
||||
// as above; recenters the player so the game quad appears directly
|
||||
// ahead of wherever they're currently standing/facing (xr_input.c's
|
||||
// xr_input_recenter_player_to_screen()) - a live-only action, unrelated
|
||||
// to questshock.config.json.
|
||||
private static native void nativeRequestResetPosition();
|
||||
|
||||
// Read-only queries - safe to call directly, see qs_config.h's own
|
||||
// comment on each of these.
|
||||
private static native boolean nativeGetRendererIsOpenGl();
|
||||
private static native int nativeGetResolutionIndex();
|
||||
// Package-visible (not private): KeyboardOverlay also calls this
|
||||
// directly, once, at its own construction time, to show the right
|
||||
// language from the start rather than waiting for this class's own
|
||||
// first periodic refresh to push a correction via applyTranslations().
|
||||
static native int nativeGetLanguage();
|
||||
private static native float nativeGetFps();
|
||||
private static native float nativeGetFrameTimeMs();
|
||||
// -1.0f if XR_META_performance_metrics (or its cpu/gpu_utilization
|
||||
// counter specifically) isn't available on this runtime - see
|
||||
// xr_session_get_cpu_utilization()/get_gpu_utilization()'s own comment.
|
||||
private static native float nativeGetCpuUtilization();
|
||||
private static native float nativeGetGpuUtilization();
|
||||
|
||||
// Called from xr_overlay.c's xr_overlay_init_jni(), on the render
|
||||
// thread, right after it resolves this class via the activity's own
|
||||
// ClassLoader (plain FindClass() can't see app classes from a thread
|
||||
// that was attached to the JVM rather than spawned from Java).
|
||||
public static void nativeInit(Activity activity) {
|
||||
if (instance == null) {
|
||||
instance = new MenuOverlay(activity);
|
||||
}
|
||||
}
|
||||
|
||||
// Polled once per frame while the menu quad is visible (see
|
||||
// xr_menu_render_if_visible()) - returns the current pixels only once
|
||||
// per redraw (null otherwise), so the native side knows when it can
|
||||
// skip re-uploading a texture that hasn't actually changed.
|
||||
public static byte[] nativeTakePixelsIfDirty() {
|
||||
if (instance == null) {
|
||||
return null;
|
||||
}
|
||||
synchronized (instance.pixelLock) {
|
||||
byte[] pixels = instance.pendingPixels;
|
||||
instance.pendingPixels = null;
|
||||
return pixels;
|
||||
}
|
||||
}
|
||||
|
||||
// u/v are the menu quad's own hit-test coordinates (0..1, top-left
|
||||
// origin) - computed by xr_input.c's ray/quad intersection against
|
||||
// xr_menu.c's reported quad geometry, forwarded here as a synthetic
|
||||
// tap. Runs on the UI thread since the View/Bitmap/Canvas objects here
|
||||
// are otherwise only ever touched from there.
|
||||
public static void nativeDispatchTouch(final float u, final float v, final boolean down) {
|
||||
// Called from qs_config.c's qs_show_restart_dialog(), on the engine's
|
||||
// own SDL thread (not the UI thread - hence runOnUiThread() here,
|
||||
// matching OverlayPanel's own dispatchTouch()/dispatchUpdateCursor())
|
||||
// after "Load Configuration" applies at least one live-appliable change
|
||||
// but finds another that's flagged requires-restart.
|
||||
public static void showRestartDialog(final String message) {
|
||||
if (instance == null) {
|
||||
return;
|
||||
}
|
||||
instance.activity.runOnUiThread(() -> instance.handleTouch(u, v, down));
|
||||
instance.activity.runOnUiThread(() -> {
|
||||
instance.restartMessageView.setText(message);
|
||||
instance.restartNowButton.setVisibility(View.VISIBLE);
|
||||
instance.mainMenuContent.setVisibility(View.GONE);
|
||||
instance.restartDialog.setVisibility(View.VISIBLE);
|
||||
instance.resetHover();
|
||||
instance.relayoutAndRedraw();
|
||||
});
|
||||
}
|
||||
|
||||
private void handleTouch(float u, float v, boolean down) {
|
||||
float x = u * WIDTH;
|
||||
float y = v * HEIGHT;
|
||||
long time = SystemClock.uptimeMillis();
|
||||
MotionEvent event = MotionEvent.obtain(
|
||||
time, time, down ? MotionEvent.ACTION_DOWN : MotionEvent.ACTION_UP, x, y, 0);
|
||||
try {
|
||||
root.dispatchTouchEvent(event);
|
||||
} finally {
|
||||
event.recycle();
|
||||
// Called from qs_config.c's qs_show_message_dialog(), same thread/
|
||||
// dispatch reasoning as showRestartDialog() above - a plain
|
||||
// informational/warning popup with nothing to restart into (currently
|
||||
// only qs_apply_language()'s missing-resource-files warning), so it
|
||||
// reuses the same dialog view minus the Restart Now button.
|
||||
public static void showMessageDialog(final String message) {
|
||||
if (instance == null) {
|
||||
return;
|
||||
}
|
||||
forceRedraw();
|
||||
instance.activity.runOnUiThread(() -> {
|
||||
instance.restartMessageView.setText(message);
|
||||
instance.restartNowButton.setVisibility(View.GONE);
|
||||
instance.mainMenuContent.setVisibility(View.GONE);
|
||||
instance.restartDialog.setVisibility(View.VISIBLE);
|
||||
instance.resetHover();
|
||||
instance.relayoutAndRedraw();
|
||||
});
|
||||
}
|
||||
|
||||
private void forceRedraw() {
|
||||
canvas.drawColor(0xFF202020);
|
||||
root.draw(canvas);
|
||||
public static byte[] nativeTakePixelsIfDirty() {
|
||||
return instance == null ? null : instance.takePixelsIfDirty();
|
||||
}
|
||||
|
||||
byte[] pixels = new byte[WIDTH * HEIGHT * 4];
|
||||
// ARGB_8888's actual in-memory byte order is R,G,B,A - matches
|
||||
// GL_RGBA/GL_UNSIGNED_BYTE on the native side with no swizzling.
|
||||
bitmap.copyPixelsToBuffer(ByteBuffer.wrap(pixels));
|
||||
synchronized (pixelLock) {
|
||||
pendingPixels = pixels;
|
||||
public static void nativeDispatchTouch(final float u, final float v, final boolean down) {
|
||||
if (instance != null) {
|
||||
instance.dispatchTouch(u, v, down);
|
||||
}
|
||||
}
|
||||
|
||||
public static void nativeUpdateCursor(final float u, final float v, final boolean visible) {
|
||||
if (instance != null) {
|
||||
instance.dispatchUpdateCursor(u, v, visible);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
package de.ladkau.questshock;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.res.ColorStateList;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Rect;
|
||||
import android.graphics.drawable.ColorDrawable;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.graphics.drawable.StateListDrawable;
|
||||
import android.os.SystemClock;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Shared machinery for an off-screen, never-attached-to-a-window View tree
|
||||
* rendered into its own OpenXR quad (see android/app/src/main/cpp/
|
||||
* xr_overlay.c, which owns the quad's swapchain and drives whichever
|
||||
* concrete subclass via JNI - construction, touch input, and pixel
|
||||
* readback). Rendering to a Bitmap and dispatching synthetic MotionEvents
|
||||
* into an unattached hierarchy both work the same way they would for an
|
||||
* attached View - draw(Canvas)/dispatchTouchEvent() don't require a
|
||||
* ViewRootImpl/window, just a measured+laid-out tree.
|
||||
*
|
||||
* Subclasses (MenuOverlay, KeyboardOverlay) provide their own content via
|
||||
* buildContent() and, if they want translucency, contentAlpha() - and, since
|
||||
* Java has no static virtual dispatch and each overlay needs its own
|
||||
* singleton, each subclass still declares its own thin nativeInit()/
|
||||
* nativeTakePixelsIfDirty()/nativeDispatchTouch()/nativeUpdateCursor()
|
||||
* static methods (matching what xr_overlay.c's JNI glue resolves by class
|
||||
* name) that just delegate into the instance methods here
|
||||
* (takePixelsIfDirty()/dispatchTouch()/dispatchUpdateCursor()).
|
||||
*/
|
||||
abstract class OverlayPanel {
|
||||
private static final float CURSOR_RADIUS = 10f;
|
||||
|
||||
// Button's own click handling (View.onTouchEvent()'s ACTION_UP case)
|
||||
// calls View.post(mPerformClick) rather than invoking performClick()
|
||||
// directly; post() queues the runnable to run once the view is attached
|
||||
// to a window and returns true immediately even when unattached, so on
|
||||
// this permanently-unattached tree that queued click silently never
|
||||
// fires - dispatchTouchEvent() still delivers the down/up events
|
||||
// correctly, only the click callback is swallowed. Registering this as
|
||||
// each button's OnTouchListener bypasses that path entirely: returning
|
||||
// true here skips View's internal onTouchEvent() (see
|
||||
// ViewGroup/View#dispatchTouchEvent), so performClick() (which still
|
||||
// runs any OnClickListener set via setOnClickListener()) is called
|
||||
// directly instead.
|
||||
protected static final View.OnTouchListener CLICK_ON_TOUCH_UP = (v, event) -> {
|
||||
switch (event.getAction()) {
|
||||
case MotionEvent.ACTION_DOWN:
|
||||
v.setPressed(true);
|
||||
break;
|
||||
case MotionEvent.ACTION_UP:
|
||||
v.setPressed(false);
|
||||
v.performClick();
|
||||
break;
|
||||
case MotionEvent.ACTION_CANCEL:
|
||||
v.setPressed(false);
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// A plain, instantly-drawn StateListDrawable rather than the platform
|
||||
// default Button background (a Material ripple): ripples animate via
|
||||
// continuous invalidation callbacks tied to a real attached window's
|
||||
// vsync, which never fires here (this tree is never attached to a
|
||||
// window) - so setPressed()/setHovered() state changes would otherwise
|
||||
// produce no visible change at all, independent of this project's own
|
||||
// touch handling (which already calls setPressed() correctly - see
|
||||
// CLICK_ON_TOUCH_UP above). Every button this project creates
|
||||
// (MenuOverlay, KeyboardOverlay) should use this (or
|
||||
// MenuOverlay.selectableButtonBackground(), which adds a persistent
|
||||
// "selected" state on top of the same normal/hovered/pressed colors) -
|
||||
// paired with buttonTextColors() below, since the normal/hovered state
|
||||
// is light enough to need dark text while pressed/selected need white.
|
||||
protected static Drawable buttonBackground() {
|
||||
StateListDrawable drawable = new StateListDrawable();
|
||||
drawable.addState(new int[]{android.R.attr.state_pressed}, new ColorDrawable(PRESSED_COLOR));
|
||||
drawable.addState(new int[]{android.R.attr.state_hovered}, new ColorDrawable(HOVERED_COLOR));
|
||||
drawable.addState(new int[]{}, new ColorDrawable(NORMAL_COLOR));
|
||||
return drawable;
|
||||
}
|
||||
|
||||
// See buttonBackground() above - light gray (normal/hovered) needs dark
|
||||
// text; the accent colors (pressed, and MenuOverlay's own "selected"
|
||||
// state) need white. Every button should get both this and
|
||||
// buttonBackground()/selectableButtonBackground() together.
|
||||
protected static ColorStateList buttonTextColors() {
|
||||
return new ColorStateList(
|
||||
new int[][]{
|
||||
new int[]{android.R.attr.state_pressed},
|
||||
new int[]{android.R.attr.state_selected},
|
||||
new int[]{},
|
||||
},
|
||||
new int[]{0xFFFFFFFF, 0xFFFFFFFF, 0xFF202020});
|
||||
}
|
||||
|
||||
protected static final int NORMAL_COLOR = 0xFFC8C8C8;
|
||||
protected static final int HOVERED_COLOR = 0xFFAEC2E8;
|
||||
protected static final int PRESSED_COLOR = 0xFF3355AA;
|
||||
|
||||
protected final Activity activity;
|
||||
private final int width;
|
||||
private final int height;
|
||||
private final View root;
|
||||
|
||||
// Views registered via registerHoverable() (every button, from both
|
||||
// buildContent() and any later-added content) - hit-tested against the
|
||||
// cursor position on every updateCursor() call so aiming at (not
|
||||
// clicking) one visibly highlights it via android.R.attr.state_hovered
|
||||
// (a real View state since API 14, paired with buttonBackground()
|
||||
// above).
|
||||
private final List<View> hoverableViews = new ArrayList<>();
|
||||
private View hoveredView = null;
|
||||
|
||||
// contentBitmap holds just the panel's own rendered content (no cursor),
|
||||
// redrawn only when that content actually changes (redrawContent(), via
|
||||
// handleTouch() or a subclass's own key handling) via root.draw() - a
|
||||
// full off-screen View-tree traversal that gets noticeably more
|
||||
// expensive on a busier content tree. bitmap is what's actually
|
||||
// published to native (see takePixelsIfDirty()): compositeAndPublish()
|
||||
// cheaply blits contentBitmap plus the cursor circle into it. Splitting
|
||||
// these two apart matters because updateCursor() below fires every
|
||||
// single frame (~90Hz) while aiming at the panel - if it called the
|
||||
// expensive root.draw() path every time (as an earlier version did), a
|
||||
// busy content tree made each redraw slow enough that runOnUiThread()
|
||||
// posts piled up faster than the UI thread could drain them, so the
|
||||
// cursor visibly lagged behind the controller's actual aim instead of
|
||||
// tracking it.
|
||||
private final Bitmap contentBitmap;
|
||||
private final Canvas contentCanvas;
|
||||
private final Bitmap bitmap;
|
||||
private final Canvas canvas;
|
||||
private final Paint cursorPaint;
|
||||
// Draws contentBitmap at contentAlpha() - see compositeAndPublish().
|
||||
private final Paint contentAlphaPaint;
|
||||
|
||||
private final Object pixelLock = new Object();
|
||||
private byte[] pendingPixels;
|
||||
|
||||
// Only ever touched on the UI thread (both dispatchUpdateCursor() and
|
||||
// compositeAndPublish() run/are posted there) - no lock needed, unlike
|
||||
// pendingPixels above.
|
||||
private boolean cursorVisible = false;
|
||||
private float cursorX = 0f;
|
||||
private float cursorY = 0f;
|
||||
|
||||
protected OverlayPanel(Activity activity, int width, int height) {
|
||||
this.activity = activity;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
|
||||
root = buildContent();
|
||||
int widthSpec = View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY);
|
||||
int heightSpec = View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY);
|
||||
root.measure(widthSpec, heightSpec);
|
||||
root.layout(0, 0, width, height);
|
||||
|
||||
contentBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
|
||||
contentCanvas = new Canvas(contentBitmap);
|
||||
bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
|
||||
canvas = new Canvas(bitmap);
|
||||
|
||||
cursorPaint = new Paint();
|
||||
cursorPaint.setColor(0xFFFFFFFF);
|
||||
cursorPaint.setAntiAlias(true);
|
||||
|
||||
// contentAlpha() must not depend on any subclass instance state -
|
||||
// it's called here, from the superclass constructor, before the
|
||||
// subclass's own field initializers/constructor body have run (the
|
||||
// usual Java construction order: this class's fields/constructor
|
||||
// first, then the subclass's). A static constant is the only thing
|
||||
// that's safe to return.
|
||||
contentAlphaPaint = new Paint();
|
||||
contentAlphaPaint.setAlpha(contentAlpha());
|
||||
|
||||
redrawContent();
|
||||
}
|
||||
|
||||
// Builds this overlay's content View tree - called once, from this
|
||||
// class's own constructor (see the note on contentAlpha() above: don't
|
||||
// rely on subclass instance fields being initialized yet here; if a
|
||||
// subclass needs its own mutable state available while building its
|
||||
// content, e.g. a list of buttons to later relabel, initialize that
|
||||
// field's value at the top of this method instead of via a field
|
||||
// initializer).
|
||||
protected abstract View buildContent();
|
||||
|
||||
// 0-255; defaults to fully opaque. Override for translucency (see the
|
||||
// constructor's note on what's safe to depend on here).
|
||||
protected int contentAlpha() {
|
||||
return 255;
|
||||
}
|
||||
|
||||
// Polled once per frame while this overlay's quad is visible (see
|
||||
// xr_overlay_render_and_build_layer()) - returns the current pixels
|
||||
// only once per redraw (null otherwise), so the native side knows when
|
||||
// it can skip re-uploading a texture that hasn't actually changed.
|
||||
protected final byte[] takePixelsIfDirty() {
|
||||
synchronized (pixelLock) {
|
||||
byte[] pixels = pendingPixels;
|
||||
pendingPixels = null;
|
||||
return pixels;
|
||||
}
|
||||
}
|
||||
|
||||
// u/v are this quad's own hit-test coordinates (0..1, top-left origin)
|
||||
// - computed by xr_input.c's ray/quad intersection against xr_overlay.c's
|
||||
// reported quad geometry, forwarded here as a synthetic tap. Runs on the
|
||||
// UI thread since the View/Bitmap/Canvas objects here are otherwise only
|
||||
// ever touched from there.
|
||||
protected final void dispatchTouch(final float u, final float v, final boolean down) {
|
||||
activity.runOnUiThread(() -> handleTouch(u, v, down));
|
||||
}
|
||||
|
||||
// u/v are the same hit-test coordinates dispatchTouch() uses, but
|
||||
// polled once per frame regardless of click state rather than only on
|
||||
// click edges - lets the cursor track the aim ray continuously instead
|
||||
// of only jumping when a trigger is pressed. visible=false (no hand's
|
||||
// ray currently on the quad) hides it.
|
||||
protected final void dispatchUpdateCursor(final float u, final float v,
|
||||
final boolean visible) {
|
||||
activity.runOnUiThread(() -> updateCursor(u, v, visible));
|
||||
}
|
||||
|
||||
private void updateCursor(float u, float v, boolean visible) {
|
||||
float x = u * width;
|
||||
float y = v * height;
|
||||
if (visible == cursorVisible && x == cursorX && y == cursorY) {
|
||||
return;
|
||||
}
|
||||
cursorVisible = visible;
|
||||
cursorX = x;
|
||||
cursorY = y;
|
||||
if (updateHoverState(x, y, visible)) {
|
||||
// redrawContent() (called inside updateHoverState()) already
|
||||
// composited and published this frame's pixels - nothing more
|
||||
// to do.
|
||||
return;
|
||||
}
|
||||
compositeAndPublish();
|
||||
}
|
||||
|
||||
// Registers a button (or any View) to be hit-tested against the cursor
|
||||
// position - called once per button, alongside buttonBackground()/
|
||||
// setOnTouchListener(), from buildContent() (or any later content
|
||||
// rebuild).
|
||||
protected final void registerHoverable(View view) {
|
||||
hoverableViews.add(view);
|
||||
}
|
||||
|
||||
// Clears whatever's currently hovered without waiting for the cursor to
|
||||
// actually move off of it - call after hiding/showing content (e.g. a
|
||||
// tab switch), since a view that was hovered right before becoming
|
||||
// GONE would otherwise still be internally tracked as hovered (and
|
||||
// wrongly show that way again) if it's later made VISIBLE again before
|
||||
// the cursor position next changes.
|
||||
protected final void resetHover() {
|
||||
if (hoveredView != null) {
|
||||
hoveredView.setHovered(false);
|
||||
hoveredView = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Absolute bounds of view within this panel's own coordinate space
|
||||
// (i.e. relative to root, which is always laid out at (0,0,width,
|
||||
// height) - see the constructor) - View's own getLeft()/getTop() are
|
||||
// parent-relative, so this walks up the parent chain summing them.
|
||||
// Assumes view is actually part of this panel's tree (every registered
|
||||
// view always is, since registerHoverable() is only ever called from
|
||||
// buildContent()).
|
||||
private Rect absoluteBounds(View view) {
|
||||
int left = 0;
|
||||
int top = 0;
|
||||
View current = view;
|
||||
while (true) {
|
||||
left += current.getLeft();
|
||||
top += current.getTop();
|
||||
if (current == root || !(current.getParent() instanceof View)) {
|
||||
break;
|
||||
}
|
||||
current = (View) current.getParent();
|
||||
}
|
||||
return new Rect(left, top, left + view.getWidth(), top + view.getHeight());
|
||||
}
|
||||
|
||||
// Returns true if the hovered view changed (and so redrawContent() -
|
||||
// which always publishes too - already ran); false if there's nothing
|
||||
// more for updateCursor() to do beyond its own compositeAndPublish().
|
||||
private boolean updateHoverState(float x, float y, boolean visible) {
|
||||
View newHovered = null;
|
||||
if (visible) {
|
||||
for (View candidate : hoverableViews) {
|
||||
if (candidate.getVisibility() != View.VISIBLE) {
|
||||
continue;
|
||||
}
|
||||
if (absoluteBounds(candidate).contains((int) x, (int) y)) {
|
||||
newHovered = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (newHovered == hoveredView) {
|
||||
return false;
|
||||
}
|
||||
if (hoveredView != null) {
|
||||
hoveredView.setHovered(false);
|
||||
}
|
||||
if (newHovered != null) {
|
||||
newHovered.setHovered(true);
|
||||
}
|
||||
hoveredView = newHovered;
|
||||
redrawContent();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void handleTouch(float u, float v, boolean down) {
|
||||
float x = u * width;
|
||||
float y = v * height;
|
||||
long time = SystemClock.uptimeMillis();
|
||||
MotionEvent event = MotionEvent.obtain(
|
||||
time, time, down ? MotionEvent.ACTION_DOWN : MotionEvent.ACTION_UP, x, y, 0);
|
||||
try {
|
||||
root.dispatchTouchEvent(event);
|
||||
} finally {
|
||||
event.recycle();
|
||||
}
|
||||
redrawContent();
|
||||
}
|
||||
|
||||
// Re-runs the full off-screen View-tree draw (expensive - see the
|
||||
// contentBitmap field comment above) - call only when the panel's
|
||||
// actual content changed, not for the cursor-only updates
|
||||
// compositeAndPublish() below handles on its own.
|
||||
protected final void redrawContent() {
|
||||
contentCanvas.drawColor(0xFF202020);
|
||||
root.draw(contentCanvas);
|
||||
compositeAndPublish();
|
||||
}
|
||||
|
||||
// Cheap per-frame path: blits the last-rendered contentBitmap (no View
|
||||
// traversal) plus the cursor circle into bitmap and publishes it.
|
||||
// contentBitmap itself is fully opaque (redrawContent() draws an opaque
|
||||
// background first); contentAlpha() translucency is applied right here
|
||||
// instead, via contentAlphaPaint - which needs bitmap cleared to fully
|
||||
// transparent first, since otherwise each frame's alpha-blended draw
|
||||
// would blend against whatever was left over in bitmap from the
|
||||
// previous frame rather than against nothing, compounding into a
|
||||
// ghosting trail across frames.
|
||||
private void compositeAndPublish() {
|
||||
bitmap.eraseColor(0);
|
||||
canvas.drawBitmap(contentBitmap, 0, 0, contentAlphaPaint);
|
||||
if (cursorVisible) {
|
||||
canvas.drawCircle(cursorX, cursorY, CURSOR_RADIUS, cursorPaint);
|
||||
}
|
||||
|
||||
byte[] pixels = new byte[width * height * 4];
|
||||
// ARGB_8888's actual in-memory byte order is R,G,B,A - matches
|
||||
// GL_RGBA/GL_UNSIGNED_BYTE on the native side with no swizzling.
|
||||
bitmap.copyPixelsToBuffer(ByteBuffer.wrap(pixels));
|
||||
synchronized (pixelLock) {
|
||||
pendingPixels = pixels;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,6 @@ import android.content.Context;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.res.AssetManager;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Log;
|
||||
import android.view.SurfaceHolder;
|
||||
import androidx.core.app.ActivityCompat;
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package de.ladkau.questshock;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Best-effort (not professionally reviewed) translations for this app's own
|
||||
* UI chrome text - the menu/keyboard panels this project draws itself, not
|
||||
* the game's own resource-string text (which already has its own separate
|
||||
* English/French/German data files). Selected by gShockPrefs.goLanguage (0
|
||||
* English/1 French/2 German, Prefs.h) via MenuOverlay's native language
|
||||
* query (qs_config_current_language()) - see MenuOverlay's periodic header-
|
||||
* refresh timer, which also pushes it to KeyboardOverlay.
|
||||
*
|
||||
* Deliberately excludes the keyboard's own alphanumeric key labels (a-z,
|
||||
* Tab, Space, Esc, ...) - those represent physical key positions/characters
|
||||
* rather than translatable UI copy - and the restart-confirmation dialog's
|
||||
* message text, which is built in native code (qs_config.c's
|
||||
* qs_show_restart_dialog()) with an interpolated option-name list and is
|
||||
* not currently reachable in practice (see CONFIG.md - neither Video
|
||||
* Options option is flagged restart-required today).
|
||||
*/
|
||||
final class Strings {
|
||||
static final int ENGLISH = 0;
|
||||
static final int FRENCH = 1;
|
||||
static final int GERMAN = 2;
|
||||
|
||||
private static final Map<String, String[]> TABLE = new HashMap<>();
|
||||
|
||||
private static void put(String key, String en, String fr, String de) {
|
||||
TABLE.put(key, new String[]{en, fr, de});
|
||||
}
|
||||
|
||||
static {
|
||||
put("keyboard", "Keyboard", "Clavier", "Tastatur");
|
||||
put("save_configuration", "Save Configuration", "Enregistrer la configuration",
|
||||
"Konfiguration speichern");
|
||||
put("load_configuration", "Load Configuration", "Charger la configuration",
|
||||
"Konfiguration laden");
|
||||
put("reset_position", "Reset Position", "Réinitialiser la position",
|
||||
"Position zurücksetzen");
|
||||
put("main_menu", "Main Menu", "Menu principal", "Hauptmenü");
|
||||
put("video_options", "Video Options", "Options vidéo", "Videooptionen");
|
||||
put("dismiss", "Dismiss", "Fermer", "Schließen");
|
||||
put("restart_now", "Restart Now", "Redémarrer maintenant", "Jetzt neu starten");
|
||||
put("renderer", "Renderer", "Moteur de rendu", "Renderer");
|
||||
put("resolution", "Resolution", "Résolution", "Auflösung");
|
||||
put("software", "Software", "Logiciel", "Software");
|
||||
put("language", "Language", "Langue", "Sprache");
|
||||
put("english", "English", "Anglais", "Englisch");
|
||||
put("french", "French", "Français", "Französisch");
|
||||
put("german", "German", "Allemand", "Deutsch");
|
||||
// "OpenGL" is a proper/brand name - not translated, same in every
|
||||
// language, so it has no entry here; call sites just use it as a
|
||||
// literal.
|
||||
put("keyboard_drag_title", "Keyboard - drag here to move",
|
||||
"Clavier - faites glisser ici pour déplacer",
|
||||
"Tastatur - hier ziehen zum Verschieben");
|
||||
put("close", "Close", "Fermer", "Schließen");
|
||||
}
|
||||
|
||||
private Strings() {}
|
||||
|
||||
static String get(String key, int language) {
|
||||
String[] variants = TABLE.get(key);
|
||||
if (variants == null) {
|
||||
return key;
|
||||
}
|
||||
if (language < 0 || language >= variants.length) {
|
||||
language = ENGLISH;
|
||||
}
|
||||
return variants[language];
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,18 @@ buildscript {
|
||||
google()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.1.1'
|
||||
// 8.5.1+ needed for AGP to automatically 16 KB-align native
|
||||
// libraries' zip entries within the APK - the actual .so's are
|
||||
// already ELF-segment-aligned for this regardless of AGP version
|
||||
// (see build-image/Dockerfile's ANDROID_16KB_LDFLAGS and
|
||||
// app/build.gradle's own CMAKE_SHARED_LINKER_FLAGS), but that alone
|
||||
// doesn't get them 16 KB-aligned *within the APK's zip*, which is
|
||||
// what Android Studio's "not compatible with 16 KB devices"
|
||||
// warning is actually about. Needs Gradle 8.7+ (see
|
||||
// gradle/wrapper/gradle-wrapper.properties) and JDK 17 in whatever
|
||||
// runs gradlew (already the case - see build-image/Dockerfile's
|
||||
// openjdk-17-jdk-headless).
|
||||
classpath 'com.android.tools.build:gradle:8.5.2'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
--- a/src/MacSrc/Shock.c
|
||||
+++ b/src/MacSrc/Shock.c
|
||||
@@ -162,6 +162,17 @@
|
||||
@@ -162,9 +162,26 @@
|
||||
void InitSDL() {
|
||||
SDL_SetHint(SDL_HINT_NO_SIGNAL_HANDLERS, "1");
|
||||
SDL_SetHint(SDL_HINT_RENDER_DRIVER, "opengl");
|
||||
@@ -12,9 +12,18 @@
|
||||
+ // 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");
|
||||
+ // it instead of AAudio. Uses SDL_HINT_OVERRIDE priority so nothing set
|
||||
+ // earlier (or by SDL/the platform) can take precedence over it.
|
||||
+ SDL_SetHintWithPriority(SDL_HINT_AUDIODRIVER, "openslES", SDL_HINT_OVERRIDE);
|
||||
+#endif
|
||||
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_TIMER | SDL_INIT_AUDIO) < 0) {
|
||||
DEBUG("%s: Init failed", __FUNCTION__);
|
||||
}
|
||||
+#ifdef __ANDROID__
|
||||
+ // Standing diagnostic: reports which audio driver SDL actually selected,
|
||||
+ // to confirm the openslES override above took effect on-device.
|
||||
+ INFO("SDL audio driver: %s", SDL_GetCurrentAudioDriver());
|
||||
+#endif
|
||||
|
||||
// TODO: figure out some universal set of settings that work...
|
||||
#ifdef __ANDROID__
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
--- a/src/MacSrc/ShockBitmap.c
|
||||
+++ b/src/MacSrc/ShockBitmap.c
|
||||
@@ -42,11 +42,26 @@
|
||||
@@ -27,6 +27,9 @@
|
||||
#include "Shock.h"
|
||||
#include "ShockBitmap.h"
|
||||
#include "2d.h"
|
||||
+#ifdef __ANDROID__
|
||||
+#include "OpenGL.h"
|
||||
+#endif
|
||||
|
||||
//--------------------
|
||||
// Globals
|
||||
@@ -42,11 +45,26 @@
|
||||
|
||||
SDL_RenderClear(renderer);
|
||||
|
||||
@@ -27,3 +37,32 @@
|
||||
|
||||
SDL_RenderSetLogicalSize(renderer, width, height);
|
||||
|
||||
@@ -54,6 +72,28 @@
|
||||
|
||||
gScreenWide = width;
|
||||
gScreenHigh = height;
|
||||
+
|
||||
+#ifdef __ANDROID__
|
||||
+ // Skipping SDL_SetWindowSize() above (see the comment for why) also
|
||||
+ // means no native resize event ever fires on Android when the in-game
|
||||
+ // resolution changes - opengl_resize() (OpenGL.cc) only ever runs from
|
||||
+ // that event (see sdl_events.c) or once at OpenGL init, and it's what
|
||||
+ // (re)creates backupBuffer, the offscreen 3D-view render target, sized
|
||||
+ // to the logical resolution just changed above. Without this, it stays
|
||||
+ // stuck at whatever size it was at launch while the rest of the game
|
||||
+ // (drawSurface/gScreenWide/gScreenHigh, just updated above) moves on to
|
||||
+ // the new resolution - the actual cause of the display breaking and
|
||||
+ // becoming unresponsive to further input after changing resolution
|
||||
+ // in-game. Call it directly instead - after gScreenWide/gScreenHigh
|
||||
+ // update above, same as the desktop resize event (delivered via
|
||||
+ // SDL_PollEvent(), so always processed after this function has already
|
||||
+ // returned) - using the same physical window size init_opengl() itself
|
||||
+ // used (SDL_GetWindowSize()); that never changes on Android, only the
|
||||
+ // logical size set above does.
|
||||
+ int physWidth, physHeight;
|
||||
+ SDL_GetWindowSize(window, &physWidth, &physHeight);
|
||||
+ opengl_resize(physWidth, physHeight);
|
||||
+#endif
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------------
|
||||
|
||||
@@ -199,11 +199,18 @@
|
||||
#include "InitMac.h"
|
||||
#include "Modding.h"
|
||||
#include "OpenGL.h"
|
||||
@@ -301,6 +305,21 @@
|
||||
@@ -301,6 +305,28 @@
|
||||
}
|
||||
|
||||
void SDLDraw() {
|
||||
+#ifdef __ANDROID__
|
||||
+ // Diagnostic: logs once per transition (including the very first
|
||||
+ // frame) between the OpenGL 3D render path and the plain
|
||||
+ // software-composited 2D path (splash/menus/cutscenes) below - so a
|
||||
+ // logcat capture immediately shows which one is active without
|
||||
+ // needing to infer it from screenshots.
|
||||
+ static int s_last_render_mode = -1; // -1 = not yet logged
|
||||
+
|
||||
+ // 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
|
||||
@@ -221,17 +228,25 @@
|
||||
if (should_opengl_swap()) {
|
||||
// We want the UI background to be transparent!
|
||||
sdlPalette->colors[255].a = 0x00;
|
||||
@@ -310,14 +329,27 @@
|
||||
@@ -310,14 +336,35 @@
|
||||
|
||||
// Set the palette back, and we are done
|
||||
sdlPalette->colors[255].a = 0xff;
|
||||
+#ifdef __ANDROID__
|
||||
+ if (s_last_render_mode != 1) {
|
||||
+ s_last_render_mode = 1;
|
||||
+ INFO("Game rendering active: OpenGL (3D) mode");
|
||||
+ }
|
||||
+ xr_frame_end();
|
||||
+#endif
|
||||
return;
|
||||
}
|
||||
|
||||
+#ifdef __ANDROID__
|
||||
+ if (s_last_render_mode != 0) {
|
||||
+ s_last_render_mode = 0;
|
||||
+ INFO("Game rendering active: software (2D) mode");
|
||||
+ }
|
||||
+ // 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
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
--- a/src/MacSrc/OpenGL.cc 2026-08-22 09:00:00.000000000 +0200
|
||||
+++ b/src/MacSrc/OpenGL.cc 2026-08-22 09:05:00.000000000 +0200
|
||||
@@ -562,7 +562,14 @@
|
||||
static bool opengl_cache_texture(CachedTexture toCache, grs_bitmap *bm) {
|
||||
SDL_GL_MakeCurrent(window, context);
|
||||
|
||||
- glPixelStorei(GL_UNPACK_ROW_LENGTH, bm->row);
|
||||
+ // GL_UNPACK_ROW_LENGTH must describe the buffer actually passed to
|
||||
+ // glTexImage2D() below (toCache.converted->pixels, a separately
|
||||
+ // allocated SDL_PIXELFORMAT_RGBA32 surface from
|
||||
+ // SDL_ConvertSurfaceFormat()) - not bm->row, the original 8-bit
|
||||
+ // source bitmap's own unrelated row stride. Using bm->row here fed
|
||||
+ // gl4es's GL_UNPACK_ROW_LENGTH emulation the wrong stride whenever
|
||||
+ // the two differed, corrupting the texture upload.
|
||||
+ glPixelStorei(GL_UNPACK_ROW_LENGTH, toCache.converted->pitch / 4);
|
||||
|
||||
if (texturesByBitsPtr.size() < MAX_CACHED_TEXTURES) {
|
||||
// We have enough room, generate the new texture
|
||||
@@ -696,7 +703,10 @@
|
||||
t->lastDrawTime = *tmd_ticks;
|
||||
|
||||
if (isDirty) {
|
||||
- glPixelStorei(GL_UNPACK_ROW_LENGTH, bm->row);
|
||||
+ // Same fix as opengl_cache_texture() above - t->converted->pixels
|
||||
+ // is what's actually uploaded, so its own pitch is what
|
||||
+ // GL_UNPACK_ROW_LENGTH needs to describe, not bm->row.
|
||||
+ glPixelStorei(GL_UNPACK_ROW_LENGTH, t->converted->pitch / 4);
|
||||
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, bm->w, bm->h, 0, GL_RGBA, GL_UNSIGNED_BYTE, t->converted->pixels);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
--- a/src/MacSrc/OpenGL.cc
|
||||
+++ b/src/MacSrc/OpenGL.cc
|
||||
@@ -566,7 +566,11 @@
|
||||
glDisableVertexAttribArray(0);
|
||||
glDisableVertexAttribArray(tcAttrib);
|
||||
glDisableVertexAttribArray(lightAttrib);
|
||||
- glFlush();
|
||||
+ // glFinish(), not glFlush(): xr_swapchain_release() (called by our
|
||||
+ // caller right after this returns) hands the image to the OpenXR
|
||||
+ // runtime for cross-process consumption, which requires the GPU write
|
||||
+ // to have actually landed, not just be queued.
|
||||
+ glFinish();
|
||||
|
||||
SDL_FreeSurface(uiRgba);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
--- a/src/MacSrc/OpenGL.cc
|
||||
+++ b/src/MacSrc/OpenGL.cc
|
||||
@@ -525,6 +525,13 @@
|
||||
bind_texture(s_uiTexture);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
|
||||
+ // uiRgba is tightly packed, but gl4es implements GL_UNPACK_ROW_LENGTH/
|
||||
+ // GL_UNPACK_ALIGNMENT client-side and persists whatever was last set
|
||||
+ // across draw calls in the same context. Reset both to the packed
|
||||
+ // defaults here so this upload doesn't inherit unrelated unpack state
|
||||
+ // set elsewhere in the frame.
|
||||
+ glPixelStorei(GL_UNPACK_ROW_LENGTH, 0);
|
||||
+ glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
|
||||
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, uiRgba->w, uiRgba->h, 0, GL_RGBA, GL_UNSIGNED_BYTE,
|
||||
uiRgba->pixels);
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
--- a/src/MacSrc/SDLSound.c
|
||||
+++ b/src/MacSrc/SDLSound.c
|
||||
@@ -42,7 +42,7 @@
|
||||
}
|
||||
|
||||
if (Mix_OpenAudio(48000, AUDIO_S16SYS, 2, 2048) < 0) {
|
||||
- ERROR("%s: Couldn't open audio device", __FUNCTION__);
|
||||
+ ERROR("%s: Couldn't open audio device: %s", __FUNCTION__, SDL_GetError());
|
||||
}
|
||||
|
||||
Mix_AllocateChannels(SND_MAX_SAMPLES);
|
||||
@@ -0,0 +1,181 @@
|
||||
--- a/src/MacSrc/SDLSound.c
|
||||
+++ b/src/MacSrc/SDLSound.c
|
||||
@@ -19,24 +19,6 @@
|
||||
|
||||
// Startup the sound system
|
||||
|
||||
- SDL_AudioSpec spec, obtained;
|
||||
- spec.freq = 48000;
|
||||
- spec.format = AUDIO_S16SYS;
|
||||
- spec.channels = 2;
|
||||
- spec.samples = 2048;
|
||||
- spec.callback = AudioStreamCallback;
|
||||
- spec.userdata = (void *)&cutscene_audiostream;
|
||||
-
|
||||
- extern SDL_AudioDeviceID device;
|
||||
- device = SDL_OpenAudioDevice(NULL, 0, &spec, &obtained, 0);
|
||||
-
|
||||
- if (device == 0) {
|
||||
- ERROR("Could not open SDL audio: %s", SDL_GetError());
|
||||
- } else {
|
||||
- INFO("Opened Music Stream, deviceID %d, freq %d, size %d, format %d, channels %d, samples %d", device,
|
||||
- obtained.freq, obtained.size, obtained.format, obtained.channels, obtained.samples);
|
||||
- }
|
||||
-
|
||||
if (Mix_Init(MIX_INIT_MP3) < 0) {
|
||||
ERROR("%s: Init failed", __FUNCTION__);
|
||||
}
|
||||
@@ -50,10 +32,18 @@
|
||||
Mix_HookMusic(MusicCallback, (void *)&MusicDev);
|
||||
Mix_VolumeMusic(MIX_MAX_VOLUME); // use max volume for music stream
|
||||
|
||||
+ // Cutscene/audiolog playback (AudioStreamCallback, see cutsloop.c) used to
|
||||
+ // run through its own SDL_OpenAudioDevice() device, separate from this
|
||||
+ // one - SDL only ever allows one open non-capture device on Android's
|
||||
+ // audio backends, so that second open always failed with "Audio device
|
||||
+ // already open" and both SFX and MIDI music (both routed through this
|
||||
+ // Mix_OpenAudio device) silently never played. Mixed into this device's
|
||||
+ // post-mix stage instead, so there is only ever one device open.
|
||||
+ Mix_SetPostMix(AudioStreamCallback, (void *)&cutscene_audiostream);
|
||||
+
|
||||
InitReadXMI();
|
||||
|
||||
atexit(Mix_CloseAudio);
|
||||
- atexit(SDL_CloseAudio);
|
||||
|
||||
return OK;
|
||||
}
|
||||
--- a/src/MacSrc/Shock.c
|
||||
+++ b/src/MacSrc/Shock.c
|
||||
@@ -60,8 +60,6 @@
|
||||
SDL_Palette *sdlPalette;
|
||||
SDL_Renderer *renderer;
|
||||
|
||||
-SDL_AudioDeviceID device;
|
||||
-
|
||||
int num_args;
|
||||
char **arg_values;
|
||||
|
||||
--- a/src/GameSrc/cutsloop.c
|
||||
+++ b/src/GameSrc/cutsloop.c
|
||||
@@ -83,15 +83,25 @@
|
||||
extern char FrnSubtitle[256];
|
||||
extern char GerSubtitle[256];
|
||||
|
||||
-extern SDL_AudioDeviceID device;
|
||||
-
|
||||
-
|
||||
+// Runs as SDL_mixer's post-mix hook (see snd_start_digital() in
|
||||
+// SDLSound.c) rather than a dedicated SDL audio device's callback, so
|
||||
+// SFX/MIDI music sharing the same device are already mixed into `stream`
|
||||
+// by the time this runs - mix in rather than overwrite, so they aren't
|
||||
+// silenced whenever a cutscene or audiolog is playing.
|
||||
void AudioStreamCallback(void *userdata, unsigned char *stream, int len)
|
||||
{
|
||||
+ static Uint8 mixbuf[MOVIE_DEFAULT_BLOCKLEN * 4];
|
||||
SDL_AudioStream *as = *(SDL_AudioStream **)userdata;
|
||||
|
||||
- if (as != NULL && SDL_AudioStreamAvailable(as) > 0)
|
||||
- SDL_AudioStreamGet(as, stream, len);
|
||||
+ if (as == NULL || SDL_AudioStreamAvailable(as) <= 0)
|
||||
+ return;
|
||||
+
|
||||
+ if (len > (int)sizeof(mixbuf))
|
||||
+ len = (int)sizeof(mixbuf);
|
||||
+
|
||||
+ int got = SDL_AudioStreamGet(as, mixbuf, len);
|
||||
+ if (got > 0)
|
||||
+ SDL_MixAudioFormat(stream, mixbuf, AUDIO_S16SYS, got, SDL_MIX_MAXVOLUME);
|
||||
}
|
||||
|
||||
|
||||
@@ -153,11 +163,13 @@
|
||||
|
||||
if (cutscene_audiostream != NULL)
|
||||
{
|
||||
- SDL_PauseAudioDevice(device, 1);
|
||||
- SDL_Delay(1);
|
||||
-
|
||||
+ // AudioStreamCallback (SDL_mixer's post-mix hook) runs continuously on
|
||||
+ // SDL_mixer's own audio thread and reads this pointer - lock around
|
||||
+ // freeing it so that thread can't be mid-read when it's freed.
|
||||
+ SDL_LockAudio();
|
||||
SDL_FreeAudioStream(cutscene_audiostream);
|
||||
cutscene_audiostream = NULL;
|
||||
+ SDL_UnlockAudio();
|
||||
|
||||
if (cutscene_audiobuffer)
|
||||
{
|
||||
@@ -184,8 +196,6 @@
|
||||
|
||||
if (cutscene_audiostream)
|
||||
{
|
||||
- SDL_PauseAudioDevice(device, 0);
|
||||
-
|
||||
if (cutscene_audiobuffer_size > 0)
|
||||
{
|
||||
// === adjust volume in buffer here ===
|
||||
@@ -325,10 +335,9 @@
|
||||
|
||||
if (sfx_on)
|
||||
{
|
||||
- SDL_PauseAudioDevice(device, 1);
|
||||
- SDL_Delay(1);
|
||||
-
|
||||
+ SDL_LockAudio();
|
||||
cutscene_audiostream = SDL_NewAudioStream(AUDIO_U8, 1, fix_int(amovie->a.sampleRate), AUDIO_S16SYS, 2, 48000);
|
||||
+ SDL_UnlockAudio();
|
||||
|
||||
cutscene_audiobuffer_pos = cutscene_audiobuffer;
|
||||
}
|
||||
--- a/src/GameSrc/audiolog.c
|
||||
+++ b/src/GameSrc/audiolog.c
|
||||
@@ -61,8 +61,6 @@
|
||||
extern uchar curr_alog_vol;
|
||||
extern char which_lang;
|
||||
|
||||
-extern SDL_AudioDeviceID device;
|
||||
-
|
||||
errtype audiolog_init(void) { return OK; }
|
||||
|
||||
errtype audiolog_play(int email_id) {
|
||||
@@ -114,10 +112,9 @@
|
||||
|
||||
DEBUG("%s: Playing email", __FUNCTION__);
|
||||
|
||||
- SDL_PauseAudioDevice(device, 1);
|
||||
- SDL_Delay(1);
|
||||
-
|
||||
+ SDL_LockAudio();
|
||||
cutscene_audiostream = SDL_NewAudioStream(AUDIO_U8, 1, fix_int(palog->a.sampleRate), AUDIO_S16SYS, 2, 48000);
|
||||
+ SDL_UnlockAudio();
|
||||
|
||||
audiolog_audiobuffer_pos = audiolog_audiobuffer;
|
||||
|
||||
@@ -150,11 +147,14 @@
|
||||
}
|
||||
|
||||
if (cutscene_audiostream != NULL) {
|
||||
- SDL_PauseAudioDevice(device, 1);
|
||||
- SDL_Delay(1);
|
||||
-
|
||||
+ // AudioStreamCallback (SDL_mixer's post-mix hook, see cutsloop.c)
|
||||
+ // runs continuously on SDL_mixer's own audio thread and reads this
|
||||
+ // pointer - lock around freeing it so that thread can't be
|
||||
+ // mid-read when it's freed.
|
||||
+ SDL_LockAudio();
|
||||
SDL_FreeAudioStream(cutscene_audiostream);
|
||||
cutscene_audiostream = NULL;
|
||||
+ SDL_UnlockAudio();
|
||||
|
||||
if (audiolog_audiobuffer) {
|
||||
free(audiolog_audiobuffer);
|
||||
@@ -177,8 +177,6 @@
|
||||
|
||||
errtype audiolog_loop_callback(void) {
|
||||
if (cutscene_audiostream) {
|
||||
- SDL_PauseAudioDevice(device, 0);
|
||||
-
|
||||
if (audiolog_audiobuffer_size > 0) {
|
||||
int i, vol = curr_alog_vol * 127 / 100; // convert from 0-100 to 0-127
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
--- a/src/MusicSrc/MusicDevice.c
|
||||
+++ b/src/MusicSrc/MusicDevice.c
|
||||
@@ -569,6 +569,14 @@
|
||||
// connected
|
||||
ndev->dev.isOpen = 1;
|
||||
ndev->dev.outputIndex = outputIndex;
|
||||
+#else
|
||||
+ // No native MIDI backend implemented for this platform (e.g. Android) -
|
||||
+ // report failure so InitDecXMI()'s caller falls back to another device
|
||||
+ // instead of treating an unopened device as successfully initialized.
|
||||
+ (void)outputIndex;
|
||||
+ (void)samplerate;
|
||||
+ WARN("NativeMidiInit(): native MIDI is not implemented on this platform");
|
||||
+ return -1;
|
||||
#endif
|
||||
// suppress compiler warnings
|
||||
(void)outputIndex;
|
||||
@@ -0,0 +1,455 @@
|
||||
--- a/src/MacSrc/OpenGL.cc
|
||||
+++ b/src/MacSrc/OpenGL.cc
|
||||
@@ -391,7 +391,16 @@
|
||||
}
|
||||
|
||||
void opengl_resize(int width, int height) {
|
||||
+#ifndef __ANDROID__
|
||||
+ // Skipped on Android - see opengl_swap_and_restore()'s comment below for
|
||||
+ // why re-asserting an already-current context/surface every call breaks
|
||||
+ // gl4es's own "current framebuffer" bookkeeping, even though the real
|
||||
+ // driver binding never actually changes. This turned out to be the
|
||||
+ // actual source of the persistent per-frame GL_INVALID_OPERATION (1282)
|
||||
+ // that plagued 3D rendering: this same unconditional call was present at
|
||||
+ // the top of nearly every function in this file, not just here.
|
||||
SDL_GL_MakeCurrent(window, context);
|
||||
+#endif
|
||||
|
||||
int logical_width, logical_height;
|
||||
SDL_RenderGetLogicalSize(renderer, &logical_width, &logical_height);
|
||||
@@ -443,7 +452,10 @@
|
||||
}
|
||||
|
||||
void opengl_end_frame() {
|
||||
+#ifndef __ANDROID__
|
||||
+ // See opengl_resize()'s comment above.
|
||||
SDL_GL_MakeCurrent(window, context);
|
||||
+#endif
|
||||
|
||||
// Done rendering to the frame buffer, reset back to normal
|
||||
BindFrameBuffer(nullptr);
|
||||
@@ -470,7 +482,10 @@
|
||||
static bool nightsight_active() { return WareActive(player_struct.hardwarez_status[HARDWARE_GOGGLE_INFRARED]); }
|
||||
|
||||
void opengl_start_frame() {
|
||||
+#ifndef __ANDROID__
|
||||
+ // See opengl_resize()'s comment above.
|
||||
SDL_GL_MakeCurrent(window, context);
|
||||
+#endif
|
||||
|
||||
// Start rendering to our frame buffer canvas
|
||||
BindFrameBuffer(&backupBuffer);
|
||||
@@ -500,6 +515,130 @@
|
||||
}
|
||||
|
||||
#ifdef __ANDROID__
|
||||
+// 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. That means every one of this file's
|
||||
+// glBegin(GL_TRIANGLE_STRIP)-based draws - not just the UI-overlay quad
|
||||
+// this was first found on, but also opengl_light_tmap() (every textured
|
||||
+// wall/floor polygon), opengl_bitmap() (sprites) and opengl_draw_poly()
|
||||
+// (flat/gouraud polygons) via draw_vertex() - draws every vertex with
|
||||
+// whatever texcoord/light was set for the *last* vertex instead of its
|
||||
+// own, which is what actually produced flat-colored/untextured world
|
||||
+// geometry after this patch's first revision only fixed the final
|
||||
+// screen-composite quad (the one bug this was originally found on).
|
||||
+//
|
||||
+// android_imm_begin()/android_imm_vertex()/android_imm_end() below emulate
|
||||
+// just enough of glBegin(GL_TRIANGLE_STRIP)/glVertexAttrib*()/glVertex3f()
|
||||
+// immediate mode to replace every such call site on Android, by buffering
|
||||
+// vertices and flushing them as one real glDrawArrays() call - the engine's
|
||||
+// other rendering still uses real immediate mode for attribute 0 elsewhere
|
||||
+// on desktop builds, which depends on it not having an enabled array bound,
|
||||
+// so android_imm_end() always disables the arrays it enables before
|
||||
+// returning.
|
||||
+struct AndroidImmVertex {
|
||||
+ float x, y, z;
|
||||
+ float u, v;
|
||||
+ float light;
|
||||
+};
|
||||
+
|
||||
+// Arbitrarily-sized, but generous: the engine's polygons (room walls/
|
||||
+// floors, sprites, screen-filling quads) are all small, hand-authored
|
||||
+// shapes, never anywhere near this many vertices.
|
||||
+static AndroidImmVertex s_immVerts[64];
|
||||
+static int s_immVertCount;
|
||||
+static GLint s_immTcAttrib;
|
||||
+static GLint s_immLightAttrib;
|
||||
+
|
||||
+// tcAttrib < 0 means this draw has no texcoord attribute (flat/gouraud
|
||||
+// polygons via opengl_draw_poly()) - matches draw_vertex()'s own
|
||||
+// `tcAttrib >= 0` check below.
|
||||
+static void android_imm_begin(GLint tcAttrib, GLint lightAttrib) {
|
||||
+ s_immVertCount = 0;
|
||||
+ s_immTcAttrib = tcAttrib;
|
||||
+ s_immLightAttrib = lightAttrib;
|
||||
+}
|
||||
+
|
||||
+static void android_imm_vertex(float x, float y, float z, float u, float v, float light) {
|
||||
+ if (s_immVertCount >= (int)(sizeof(s_immVerts) / sizeof(s_immVerts[0]))) {
|
||||
+ WARN("android_imm_vertex: dropping vertex past the %d-vertex limit", s_immVertCount);
|
||||
+ return;
|
||||
+ }
|
||||
+ AndroidImmVertex &vert = s_immVerts[s_immVertCount++];
|
||||
+ vert.x = x;
|
||||
+ vert.y = y;
|
||||
+ vert.z = z;
|
||||
+ vert.u = u;
|
||||
+ vert.v = v;
|
||||
+ vert.light = light;
|
||||
+}
|
||||
+
|
||||
+static void android_imm_end() {
|
||||
+ if (s_immVertCount < 3)
|
||||
+ return;
|
||||
+ glEnableVertexAttribArray(0);
|
||||
+ if (s_immTcAttrib >= 0)
|
||||
+ glEnableVertexAttribArray(s_immTcAttrib);
|
||||
+ glEnableVertexAttribArray(s_immLightAttrib);
|
||||
+ glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(AndroidImmVertex), &s_immVerts[0].x);
|
||||
+ if (s_immTcAttrib >= 0)
|
||||
+ glVertexAttribPointer(s_immTcAttrib, 2, GL_FLOAT, GL_FALSE, sizeof(AndroidImmVertex),
|
||||
+ &s_immVerts[0].u);
|
||||
+ glVertexAttribPointer(s_immLightAttrib, 1, GL_FLOAT, GL_FALSE, sizeof(AndroidImmVertex),
|
||||
+ &s_immVerts[0].light);
|
||||
+ glDrawArrays(GL_TRIANGLE_STRIP, 0, s_immVertCount);
|
||||
+ glDisableVertexAttribArray(0);
|
||||
+ if (s_immTcAttrib >= 0)
|
||||
+ glDisableVertexAttribArray(s_immTcAttrib);
|
||||
+ glDisableVertexAttribArray(s_immLightAttrib);
|
||||
+}
|
||||
+
|
||||
+// Same underlying bug as android_imm_*() above, for opengl_draw_star():
|
||||
+// opengl_begin_stars()/opengl_draw_star()/opengl_end_stars() draw up to
|
||||
+// NUM_STARS (rendtool.c) individual GL_POINTS between one begin/end pair,
|
||||
+// each with its own per-star brightness via glVertexAttrib1f(lightAttrib,
|
||||
+// ...)/glVertex3f() immediate mode - under gl4es that made every star on
|
||||
+// screen render at whatever brightness was set for the *last* one. Shaped
|
||||
+// differently enough from android_imm_*() above (GL_POINTS instead of
|
||||
+// GL_TRIANGLE_STRIP, far more vertices, no texcoord) to warrant its own
|
||||
+// small buffer rather than overloading that one.
|
||||
+static AndroidImmVertex s_starVerts[1000]; // NUM_STARS, GameSrc/rendtool.c
|
||||
+static int s_starVertCount;
|
||||
+static GLint s_starLightAttrib;
|
||||
+
|
||||
+static void android_stars_begin(GLint lightAttrib) {
|
||||
+ s_starVertCount = 0;
|
||||
+ s_starLightAttrib = lightAttrib;
|
||||
+}
|
||||
+
|
||||
+static void android_stars_vertex(float x, float y, float z, float light) {
|
||||
+ if (s_starVertCount >= (int)(sizeof(s_starVerts) / sizeof(s_starVerts[0]))) {
|
||||
+ WARN("android_stars_vertex: dropping star past the %d-star limit", s_starVertCount);
|
||||
+ return;
|
||||
+ }
|
||||
+ AndroidImmVertex &vert = s_starVerts[s_starVertCount++];
|
||||
+ vert.x = x;
|
||||
+ vert.y = y;
|
||||
+ vert.z = z;
|
||||
+ vert.light = light;
|
||||
+}
|
||||
+
|
||||
+static void android_stars_end() {
|
||||
+ if (s_starVertCount == 0)
|
||||
+ return;
|
||||
+ glEnableVertexAttribArray(0);
|
||||
+ glEnableVertexAttribArray(s_starLightAttrib);
|
||||
+ glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(AndroidImmVertex), &s_starVerts[0].x);
|
||||
+ glVertexAttribPointer(s_starLightAttrib, 1, GL_FLOAT, GL_FALSE, sizeof(AndroidImmVertex),
|
||||
+ &s_starVerts[0].light);
|
||||
+ glDrawArrays(GL_POINTS, 0, s_starVertCount);
|
||||
+ glDisableVertexAttribArray(0);
|
||||
+ glDisableVertexAttribArray(s_starLightAttrib);
|
||||
+}
|
||||
+
|
||||
// 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
|
||||
@@ -537,42 +676,15 @@
|
||||
|
||||
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);
|
||||
+ android_imm_begin(tcAttrib, lightAttrib);
|
||||
+ android_imm_vertex(1.0f, -1.0f, 0.0f, 1.0f, 1.0f, 1.0f);
|
||||
+ android_imm_vertex(1.0f, 1.0f, 0.0f, 1.0f, 0.0f, 1.0f);
|
||||
+ android_imm_vertex(-1.0f, -1.0f, 0.0f, 0.0f, 1.0f, 1.0f);
|
||||
+ android_imm_vertex(-1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f);
|
||||
+ android_imm_end();
|
||||
// glFinish(), not glFlush(): xr_swapchain_release() (called by our
|
||||
// caller right after this returns) hands the image to the OpenXR
|
||||
// runtime for cross-process consumption, which requires the GPU write
|
||||
@@ -642,6 +754,16 @@
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
|
||||
|
||||
+#ifdef __ANDROID__
|
||||
+ // Real per-vertex arrays, not glBegin/glVertexAttrib/glVertex3f
|
||||
+ // immediate mode - see android_imm_begin()'s comment above for why.
|
||||
+ android_imm_begin(tcAttrib, lightAttrib);
|
||||
+ android_imm_vertex(1.0f, -1.0f, 0.0f, 1.0f, 0.0f, 1.0f);
|
||||
+ android_imm_vertex(1.0f, 1.0f, 0.0f, 1.0f, 1.0f, 1.0f);
|
||||
+ android_imm_vertex(-1.0f, -1.0f, 0.0f, 0.0f, 0.0f, 1.0f);
|
||||
+ android_imm_vertex(-1.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f);
|
||||
+ android_imm_end();
|
||||
+#else
|
||||
glBegin(GL_TRIANGLE_STRIP);
|
||||
glVertexAttrib2f(tcAttrib, 1.0f, 0.0f);
|
||||
glVertexAttrib1f(lightAttrib, 1.0f);
|
||||
@@ -656,6 +778,7 @@
|
||||
glVertexAttrib1f(lightAttrib, 1.0f);
|
||||
glVertex3f(-1.0f, 1.0f, 0.0f);
|
||||
glEnd();
|
||||
+#endif
|
||||
|
||||
// Finish drawing the 3d view
|
||||
glFlush();
|
||||
@@ -709,7 +832,10 @@
|
||||
render_width = width;
|
||||
render_height = height;
|
||||
|
||||
+#ifndef __ANDROID__
|
||||
+ // See opengl_resize()'s comment above.
|
||||
SDL_GL_MakeCurrent(window, context);
|
||||
+#endif
|
||||
|
||||
int lw, lh;
|
||||
SDL_RenderGetLogicalSize(renderer, &lw, &lh);
|
||||
@@ -729,7 +855,10 @@
|
||||
}
|
||||
|
||||
static bool opengl_cache_texture(CachedTexture toCache, grs_bitmap *bm) {
|
||||
+#ifndef __ANDROID__
|
||||
+ // See opengl_resize()'s comment above.
|
||||
SDL_GL_MakeCurrent(window, context);
|
||||
+#endif
|
||||
|
||||
// GL_UNPACK_ROW_LENGTH must describe the buffer actually passed to
|
||||
// glTexImage2D() below (toCache.converted->pixels, a separately
|
||||
@@ -771,7 +900,10 @@
|
||||
|
||||
DEBUG("Clearing OpenGL texture cache.");
|
||||
|
||||
+#ifndef __ANDROID__
|
||||
+ // See opengl_resize()'s comment above.
|
||||
SDL_GL_MakeCurrent(window, context);
|
||||
+#endif
|
||||
|
||||
for (auto iter = texturesByBitsPtr.begin(); iter != texturesByBitsPtr.end();) {
|
||||
// don't free locked surfaces
|
||||
@@ -896,10 +1028,16 @@
|
||||
light = 1.0f - (clut - grd_screen->ltab) / 4096.0f;
|
||||
}
|
||||
|
||||
+#ifdef __ANDROID__
|
||||
+ (void)tcAttrib; // captured by android_imm_begin() instead - see its comment above
|
||||
+ android_imm_vertex(vertex.x / 65536.0f, vertex.y / 65536.0f, -vertex.z / 65536.0f,
|
||||
+ vertex.uv.u / 256.0f, vertex.uv.v / 256.0f, light);
|
||||
+#else
|
||||
if (tcAttrib >= 0)
|
||||
glVertexAttrib2f(tcAttrib, vertex.uv.u / 256.0, vertex.uv.v / 256.0);
|
||||
glVertexAttrib1f(lightAttrib, light);
|
||||
glVertex3f(vertex.x / 65536.0f, vertex.y / 65536.0f, -vertex.z / 65536.0f);
|
||||
+#endif
|
||||
}
|
||||
|
||||
int opengl_draw_tmap(int n, g3s_phandle *vp, grs_bitmap *bm) { return opengl_light_tmap(n, vp, bm); }
|
||||
@@ -910,7 +1048,10 @@
|
||||
return CLIP_ALL;
|
||||
}
|
||||
|
||||
+#ifndef __ANDROID__
|
||||
+ // See opengl_resize()'s comment above.
|
||||
SDL_GL_MakeCurrent(window, context);
|
||||
+#endif
|
||||
set_blend_mode(bm->flags & BMF_TRANS);
|
||||
|
||||
glUseProgram(textureShaderProgram.shaderProgram);
|
||||
@@ -928,13 +1069,21 @@
|
||||
glUniformMatrix4fv(textureShaderProgram.uniView, 1, false, ViewMatrix);
|
||||
glUniformMatrix4fv(textureShaderProgram.uniProj, 1, false, ProjectionMatrix);
|
||||
|
||||
+#ifdef __ANDROID__
|
||||
+ android_imm_begin(tcAttrib, lightAttrib);
|
||||
+#else
|
||||
glBegin(GL_TRIANGLE_STRIP);
|
||||
+#endif
|
||||
draw_vertex(*(vp[1]), tcAttrib, lightAttrib);
|
||||
draw_vertex(*(vp[0]), tcAttrib, lightAttrib);
|
||||
draw_vertex(*(vp[2]), tcAttrib, lightAttrib);
|
||||
if (n > 3)
|
||||
draw_vertex(*(vp[3]), tcAttrib, lightAttrib);
|
||||
+#ifdef __ANDROID__
|
||||
+ android_imm_end();
|
||||
+#else
|
||||
glEnd();
|
||||
+#endif
|
||||
|
||||
return CLIP_NONE;
|
||||
}
|
||||
@@ -949,7 +1098,10 @@
|
||||
return CLIP_ALL;
|
||||
}
|
||||
|
||||
+#ifndef __ANDROID__
|
||||
+ // See opengl_resize()'s comment above.
|
||||
SDL_GL_MakeCurrent(window, context);
|
||||
+#endif
|
||||
set_blend_mode(bm->flags & BMF_TRANS);
|
||||
|
||||
glUseProgram(textureShaderProgram.shaderProgram);
|
||||
@@ -976,6 +1128,14 @@
|
||||
|
||||
glUniform1i(textureShaderProgram.uniMutant, (bm->type == BMT_TLUC8) || (bm->flags == BMF_TLUC8));
|
||||
|
||||
+#ifdef __ANDROID__
|
||||
+ android_imm_begin(tcAttrib, lightAttrib);
|
||||
+ android_imm_vertex(convx(vpl[1]->x), convy(vpl[1]->y), 0.0f, 1.0f, 0.0f, light);
|
||||
+ android_imm_vertex(convx(vpl[0]->x), convy(vpl[0]->y), 0.0f, 0.0f, 0.0f, light);
|
||||
+ android_imm_vertex(convx(vpl[2]->x), convy(vpl[2]->y), 0.0f, 1.0f, 1.0f, light);
|
||||
+ android_imm_vertex(convx(vpl[3]->x), convy(vpl[3]->y), 0.0f, 0.0f, 1.0f, light);
|
||||
+ android_imm_end();
|
||||
+#else
|
||||
glBegin(GL_TRIANGLE_STRIP);
|
||||
glVertexAttrib2f(tcAttrib, 1.0f, 0.0f);
|
||||
glVertexAttrib1f(lightAttrib, light);
|
||||
@@ -990,6 +1150,7 @@
|
||||
glVertexAttrib1f(lightAttrib, light);
|
||||
glVertex3f(convx(vpl[3]->x), convy(vpl[3]->y), 0.0f);
|
||||
glEnd();
|
||||
+#endif
|
||||
|
||||
glUniform1i(textureShaderProgram.uniMutant, false);
|
||||
|
||||
@@ -1007,7 +1168,10 @@
|
||||
return CLIP_ALL;
|
||||
}
|
||||
|
||||
+#ifndef __ANDROID__
|
||||
+ // See opengl_resize()'s comment above.
|
||||
SDL_GL_MakeCurrent(window, context);
|
||||
+#endif
|
||||
|
||||
glUseProgram(colorShaderProgram.shaderProgram);
|
||||
glUniformMatrix4fv(colorShaderProgram.uniView, 1, false, ViewMatrix);
|
||||
@@ -1064,7 +1228,11 @@
|
||||
GLint lightAttrib = colorShaderProgram.lightAttrib;
|
||||
|
||||
long a = 0, b = n_verts - 1;
|
||||
+#ifdef __ANDROID__
|
||||
+ android_imm_begin(-1, lightAttrib);
|
||||
+#else
|
||||
glBegin(GL_TRIANGLE_STRIP);
|
||||
+#endif
|
||||
while (true) {
|
||||
draw_vertex(*(p[a]), -1, lightAttrib);
|
||||
if (a++ == b)
|
||||
@@ -1073,7 +1241,11 @@
|
||||
if (b-- == a)
|
||||
break;
|
||||
}
|
||||
+#ifdef __ANDROID__
|
||||
+ android_imm_end();
|
||||
+#else
|
||||
glEnd();
|
||||
+#endif
|
||||
|
||||
return CLIP_NONE;
|
||||
}
|
||||
@@ -1085,7 +1257,10 @@
|
||||
}
|
||||
|
||||
void opengl_begin_stars() {
|
||||
+#ifndef __ANDROID__
|
||||
+ // See opengl_resize()'s comment above.
|
||||
SDL_GL_MakeCurrent(window, context);
|
||||
+#endif
|
||||
|
||||
// Bind the star shader before setting the point size: on Android,
|
||||
// questshock_glPointSize() (see the __ANDROID__ block above) needs
|
||||
@@ -1108,11 +1283,19 @@
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
|
||||
|
||||
+#ifdef __ANDROID__
|
||||
+ android_stars_begin(starShaderProgram.lightAttrib);
|
||||
+#else
|
||||
glBegin(GL_POINTS);
|
||||
+#endif
|
||||
}
|
||||
|
||||
void opengl_end_stars() {
|
||||
+#ifdef __ANDROID__
|
||||
+ android_stars_end();
|
||||
+#else
|
||||
glEnd();
|
||||
+#endif
|
||||
|
||||
// Turn off the stencil test and blending
|
||||
opengl_set_stencil(0x00);
|
||||
@@ -1120,9 +1303,11 @@
|
||||
}
|
||||
|
||||
int opengl_draw_star(fix star_x, fix star_y, int c, bool anti_alias) {
|
||||
+#ifndef __ANDROID__
|
||||
+ // See opengl_resize()'s comment above.
|
||||
SDL_GL_MakeCurrent(window, context);
|
||||
-
|
||||
GLint lightAttrib = starShaderProgram.lightAttrib;
|
||||
+#endif
|
||||
|
||||
float x = fix_float(star_x);
|
||||
float y = fix_float(star_y);
|
||||
@@ -1142,8 +1327,12 @@
|
||||
int color = (std_color_base + std_color_range - 1 - c);
|
||||
color = (255 * color) / (std_color_range + 1);
|
||||
|
||||
+#ifdef __ANDROID__
|
||||
+ android_stars_vertex(x, y, -0.25f, color / 255.0f);
|
||||
+#else
|
||||
glVertexAttrib1f(lightAttrib, color / 255.0f);
|
||||
glVertex3f(x, y, -0.25f);
|
||||
+#endif
|
||||
|
||||
return CLIP_NONE;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
--- a/src/GameSrc/frsetup.c
|
||||
+++ b/src/GameSrc/frsetup.c
|
||||
@@ -635,7 +635,27 @@
|
||||
uchar old_cam_type;
|
||||
int detail;
|
||||
|
||||
+#ifdef __ANDROID__
|
||||
+ // should_opengl_swap() (unlike use_opengl(), see its own
|
||||
+ // FR_PICKUPM_MASK|FR_HACKCAM_MASK exclusion just below and the
|
||||
+ // matching one used for the STEREO_SUPPORT path in fr_send_view()
|
||||
+ // below) doesn't know which _fr context is currently being prepared -
|
||||
+ // it's a frame-level check, also used by Shock.c's SDLDraw() to pick
|
||||
+ // the frame's overall compositing path. render_hack_cameras() (see
|
||||
+ // render.c) calls fr_rend() - and so this function - a second time per
|
||||
+ // real frame for each active hack-camera monitor, rendering into a
|
||||
+ // private software (FR_DOUBLEB_MASK) canvas rather than the actual
|
||||
+ // GL-backed screen. Without this exclusion, opengl_start_frame() runs
|
||||
+ // again mid-frame for that render even though its own polygon draw
|
||||
+ // calls correctly stay on the software path (use_opengl() already
|
||||
+ // excludes them) - re-entering the OpenXR swapchain/framebuffer setup
|
||||
+ // a second time before the first call's frame has ended. Not known to
|
||||
+ // be exercised on desktop (no OpenXR swapchain there), so this fix is
|
||||
+ // kept Android-only rather than changing should_opengl_swap() itself.
|
||||
+ if (should_opengl_swap() && !(_fr_curflags & (FR_PICKUPM_MASK | FR_HACKCAM_MASK))) {
|
||||
+#else
|
||||
if(should_opengl_swap()) {
|
||||
+#endif
|
||||
opengl_start_frame();
|
||||
}
|
||||
|
||||
@@ -823,7 +843,14 @@
|
||||
|
||||
g3_end_frame();
|
||||
|
||||
+#ifdef __ANDROID__
|
||||
+ // Mirrors the same fix in fr_start_view() above - see its comment.
|
||||
+ // opengl_start_frame()/opengl_end_frame() must stay paired per real
|
||||
+ // GL frame, so this exclusion has to match that one exactly.
|
||||
+ if (should_opengl_swap() && !(_fr_curflags & (FR_PICKUPM_MASK | FR_HACKCAM_MASK))) {
|
||||
+#else
|
||||
if(should_opengl_swap()) {
|
||||
+#endif
|
||||
opengl_end_frame();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
--- a/src/MacSrc/OpenGL.cc
|
||||
+++ b/src/MacSrc/OpenGL.cc
|
||||
@@ -455,10 +455,30 @@
|
||||
#ifndef __ANDROID__
|
||||
// See opengl_resize()'s comment above.
|
||||
SDL_GL_MakeCurrent(window, context);
|
||||
-#endif
|
||||
|
||||
// Done rendering to the frame buffer, reset back to normal
|
||||
BindFrameBuffer(nullptr);
|
||||
+#else
|
||||
+ // BindFrameBuffer(nullptr) (glBindFramebuffer/glBindRenderbuffer to id
|
||||
+ // 0) means "the real window surface" - on desktop that's SDL's actual
|
||||
+ // GL window, but this app's real presentation target is never that
|
||||
+ // SDLSurface's own EGL surface at all - it's the OpenXR swapchain's
|
||||
+ // per-image FBOs, set up separately in xr_swapchain.c. Whatever
|
||||
+ // framebuffer/renderbuffer 0 actually resolves to for gl4es in this
|
||||
+ // configuration, binding it here reliably left gl4es reporting a
|
||||
+ // persistent GL_INVALID_OPERATION (1282) - confirmed by bisecting
|
||||
+ // through this whole render path with per-call checkpoints, all clean
|
||||
+ // right up until "after opengl_end_frame", the one immediately after
|
||||
+ // this exact call.
|
||||
+ //
|
||||
+ // The bind is also functionally dead on Android regardless of that:
|
||||
+ // by the time anything next draws, Shock.c's SDLDraw() has already
|
||||
+ // run xr_frame_begin() -> xr_swapchain_acquire(), which rebinds to
|
||||
+ // that frame's real swapchain FBO before opengl_swap_and_restore()
|
||||
+ // (the only thing that draws before the next opengl_start_frame()'s
|
||||
+ // own BindFrameBuffer(&backupBuffer)) runs - so skipping it changes
|
||||
+ // nothing about what actually gets rendered where.
|
||||
+#endif
|
||||
palette_dirty = false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
--- a/src/MacSrc/Shock.c
|
||||
+++ b/src/MacSrc/Shock.c
|
||||
@@ -31,6 +31,7 @@
|
||||
#include <SDL.h>
|
||||
|
||||
#ifdef __ANDROID__
|
||||
+#include "qs_config.h"
|
||||
#include "xr_session.h"
|
||||
#endif
|
||||
|
||||
@@ -109,6 +110,15 @@
|
||||
LoadHotkeyKeybinds();
|
||||
LoadMoveKeybinds();
|
||||
|
||||
+#ifdef __ANDROID__
|
||||
+ // Persistent, human-editable JSON config (questshock.config.json) - see
|
||||
+ // qs_config.h/CONFIG.md. After prefs.txt is loaded (above) so this file
|
||||
+ // can override it, before init_all() below so there's no live-vs-
|
||||
+ // restart distinction to worry about yet - every field just takes full
|
||||
+ // effect as part of this same startup.
|
||||
+ qs_config_load_startup();
|
||||
+#endif
|
||||
+
|
||||
// Process some startup arguments
|
||||
|
||||
bool show_splash = !CheckArgument("-nosplash");
|
||||
--- a/src/Libraries/INPUT/Source/sdl_events.c
|
||||
+++ b/src/Libraries/INPUT/Source/sdl_events.c
|
||||
@@ -29,6 +29,10 @@
|
||||
#include <SDL.h>
|
||||
#include <OpenGL.h>
|
||||
|
||||
+#ifdef __ANDROID__
|
||||
+#include "qs_config.h"
|
||||
+#endif
|
||||
+
|
||||
extern SDL_Window *window;
|
||||
extern SDL_Renderer *renderer;
|
||||
|
||||
@@ -481,6 +485,14 @@
|
||||
SDL_Event ev;
|
||||
|
||||
while (SDL_PollEvent(&ev)) {
|
||||
+#ifdef __ANDROID__
|
||||
+ // Custom event types qs_config_request_save()/_load() enqueue from
|
||||
+ // the "Save Configuration"/"Load Configuration" menu buttons (see
|
||||
+ // qs_config.h) - handled here, on this thread, rather than directly
|
||||
+ // from those JNI calls (which run on Android's UI thread).
|
||||
+ if (qs_config_handle_event(&ev))
|
||||
+ continue;
|
||||
+#endif
|
||||
switch (ev.type) {
|
||||
case SDL_QUIT:
|
||||
// a bit hacky at this place, but this would allow exiting the game via the window's [x] button
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# Forces a fully fresh native build - works around stageEngine sometimes
|
||||
# wrongly reporting UP-TO-DATE after android/engine-patches/ or
|
||||
# android/gl4es-patches/ changes (see gradle_stage_engine_stale_cache memory
|
||||
# note). Run from anywhere. Needs JAVA_HOME set to a JDK 17+; defaults to
|
||||
# Android Studio's bundled JDK if found.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
if [ -z "${JAVA_HOME:-}" ]; then
|
||||
if [ -d /opt/android-studio/jbr ]; then
|
||||
export JAVA_HOME=/opt/android-studio/jbr
|
||||
else
|
||||
echo "JAVA_HOME not set and no JDK found at /opt/android-studio/jbr - export it yourself" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
./gradlew --stop
|
||||
rm -rf \
|
||||
../build/android-engine \
|
||||
../build/android-gl4es \
|
||||
../build/android-prebuilt \
|
||||
app/.cxx \
|
||||
app/build
|
||||
echo "Done - next Gradle build/Run will rebuild from scratch."
|
||||
@@ -1,6 +1,7 @@
|
||||
#Thu Nov 11 18:20:34 PST 2021
|
||||
#Sat Sep 05 16:12:16 CEST 2026
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip
|
||||
distributionPath=wrapper/dists
|
||||
zipStorePath=wrapper/dists
|
||||
distributionSha256Sum=544c35d6bd849ae8a5ed0bcea39ba677dc40f49df7d1835561582da2009b961d
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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 =="
|
||||
|
||||
@@ -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 "$@"
|
||||
|
||||
@@ -13,10 +13,23 @@
|
||||
# inside it in sshock.kpf, which is just a zip file. Its res/pc/hd/ and
|
||||
# res/pc/cdrom/ trees together hold every classic-game data/sound file
|
||||
# Shockolate's res/ folder expects (res/pc/hd wins the couple of
|
||||
# filenames present in both: intro.res, objprop.dat). Needs innoextract
|
||||
# and unzip; if they're not installed locally, this script does the
|
||||
# extraction inside a throwaway Ubuntu container instead (needs docker in
|
||||
# that case, nothing else).
|
||||
# filenames present in both: intro.res, objprop.dat), EXCEPT
|
||||
# mfdfrn.res/mfdger.res (French/German MFD art - see CONFIG.md/README.md),
|
||||
# which that installer doesn't include at all - see "Optional: French/
|
||||
# German MFD art" below for where those instead come from. Needs
|
||||
# innoextract and unzip; if they're not installed locally, this script
|
||||
# does the extraction inside a throwaway Ubuntu container instead (needs
|
||||
# docker in that case, nothing else).
|
||||
#
|
||||
# Optional: French/German MFD art
|
||||
# The Enhanced Edition installer's data set doesn't include
|
||||
# mfdfrn.res/mfdger.res, so French/German are otherwise missing their
|
||||
# MFD (Multi-Function Display) art and the game refuses to switch to
|
||||
# them (see CONFIG.md). System Shock: Classic (also gog.com) does
|
||||
# include both files. If you also own it, drop its offline installer
|
||||
# (e.g. setup_system_shock_classic_2.0.0.3.exe) in this directory too -
|
||||
# entirely optional, this script still runs fine without it - and this
|
||||
# script pulls just those two files out of it into ss_ee/data/ as well.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||
@@ -27,6 +40,7 @@ fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; }
|
||||
|
||||
shopt -s nullglob
|
||||
installers=(setup_system_shock_enhanced_edition*.exe)
|
||||
classic_installers=(setup_system_shock_classic*.exe)
|
||||
shopt -u nullglob
|
||||
case "${#installers[@]}" in
|
||||
0) fail "no setup_system_shock_enhanced_edition*.exe found in $ASSETS_DIR - buy System Shock: Enhanced Edition on gog.com, download the offline installer, and drop it here" ;;
|
||||
@@ -35,6 +49,15 @@ case "${#installers[@]}" in
|
||||
esac
|
||||
INSTALLER="${installers[0]}"
|
||||
|
||||
# Optional - see "Optional: French/German MFD art" above. Empty (not
|
||||
# missing/failed) when absent; only a genuine ambiguity (more than one
|
||||
# candidate file) is a hard failure.
|
||||
case "${#classic_installers[@]}" in
|
||||
0) CLASSIC_INSTALLER="" ;;
|
||||
1) CLASSIC_INSTALLER="${classic_installers[0]}" ;;
|
||||
*) fail "multiple setup_system_shock_classic*.exe files found in $ASSETS_DIR - keep only one: ${classic_installers[*]}" ;;
|
||||
esac
|
||||
|
||||
# Extracts sshock.kpf from $1 (an installer .exe) and assembles $2 (an
|
||||
# ss_ee output directory) from its res/pc/{hd,cdrom} trees. Defined as a
|
||||
# function so the exact same logic can run either natively or, via
|
||||
@@ -69,9 +92,38 @@ run_extraction() {
|
||||
cp -a "$work/kpf/res/pc/hd/sound/." "$out_dir/sound/"
|
||||
}
|
||||
|
||||
# Extracts mfdfrn.res/mfdger.res from $1 (a System Shock: Classic installer
|
||||
# .exe) into $2 (an existing ss_ee output directory's data/ subfolder) -
|
||||
# see "Optional: French/German MFD art" above. Unlike the Enhanced Edition
|
||||
# installer, Classic's data files sit directly in the Inno Setup package
|
||||
# (no intermediate zip), so this is just a targeted innoextract --include,
|
||||
# no unzip step needed. Same declare -f-into-docker structure as
|
||||
# run_extraction() above.
|
||||
run_classic_mfd_extraction() {
|
||||
set -euo pipefail
|
||||
local installer="$1" out_dir="$2"
|
||||
work="$(mktemp -d)"
|
||||
trap 'rm -rf "${work:-}"' EXIT
|
||||
|
||||
echo "== Extracting mfdfrn.res/mfdger.res from classic installer =="
|
||||
innoextract --silent \
|
||||
--include 'app/SSHOCK/DATA/MFDFRN.RES' --include 'app/SSHOCK/DATA/MFDGER.RES' \
|
||||
-d "$work/classic" "$installer"
|
||||
|
||||
# Original DOS-era filenames are uppercase; Shockolate's resource
|
||||
# loader expects the lowercase names already used for every other
|
||||
# res/data/*.res file (case-sensitive on Linux, unlike the DOS/Windows
|
||||
# filesystems these came from).
|
||||
cp "$work/classic/app/SSHOCK/DATA/MFDFRN.RES" "$out_dir/data/mfdfrn.res"
|
||||
cp "$work/classic/app/SSHOCK/DATA/MFDGER.RES" "$out_dir/data/mfdger.res"
|
||||
}
|
||||
|
||||
if command -v innoextract >/dev/null 2>&1 && command -v unzip >/dev/null 2>&1; then
|
||||
echo "== Using local innoextract/unzip =="
|
||||
run_extraction "$ASSETS_DIR/$INSTALLER" "$OUT_DIR"
|
||||
if [ -n "$CLASSIC_INSTALLER" ]; then
|
||||
run_classic_mfd_extraction "$ASSETS_DIR/$CLASSIC_INSTALLER" "$OUT_DIR"
|
||||
fi
|
||||
else
|
||||
echo "== innoextract/unzip not found locally - using a throwaway docker container =="
|
||||
command -v docker >/dev/null 2>&1 \
|
||||
@@ -86,12 +138,18 @@ else
|
||||
apt-get update -qq >/dev/null
|
||||
apt-get install -y -qq innoextract unzip >/dev/null
|
||||
$(declare -f run_extraction)
|
||||
$(declare -f run_classic_mfd_extraction)
|
||||
run_extraction '/assets/$INSTALLER' '/assets/ss_ee'
|
||||
$([ -n "$CLASSIC_INSTALLER" ] && echo "run_classic_mfd_extraction '/assets/$CLASSIC_INSTALLER' '/assets/ss_ee'")
|
||||
chown -R \"\$HOST_UID:\$HOST_GID\" /assets/ss_ee
|
||||
"
|
||||
fi
|
||||
|
||||
echo "== Done =="
|
||||
echo "Assets extracted to $OUT_DIR"
|
||||
echo "Note: mfdfrn.res/mfdger.res (French/German MFD art) are not present in this"
|
||||
echo "installer and are only needed for those localizations - not for English play."
|
||||
if [ -n "$CLASSIC_INSTALLER" ]; then
|
||||
echo "Also extracted mfdfrn.res/mfdger.res (French/German MFD art) from $CLASSIC_INSTALLER."
|
||||
else
|
||||
echo "Note: mfdfrn.res/mfdger.res (French/German MFD art) are not present - only needed for"
|
||||
echo "French/German (see CONFIG.md); drop a System Shock: Classic installer here too to get them."
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
@echo off
|
||||
cd /d "%~dp0"
|
||||
systemshock.exe %*
|
||||
exit /b %errorlevel%
|
||||
|
Before Width: | Height: | Size: 381 KiB |
|
After Width: | Height: | Size: 641 KiB |
|
After Width: | Height: | Size: 575 KiB |
|
After Width: | Height: | Size: 707 KiB |
|
After Width: | Height: | Size: 382 KiB |
|
After Width: | Height: | Size: 413 KiB |
|
After Width: | Height: | Size: 368 KiB |
|
After Width: | Height: | Size: 435 KiB |
|
After Width: | Height: | Size: 276 KiB |
|
After Width: | Height: | Size: 209 KiB |
@@ -0,0 +1,132 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="210mm"
|
||||
height="297mm"
|
||||
viewBox="0 0 210 297"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
|
||||
sodipodi:docname="sketch_main_menu.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:zoom="2.5287798"
|
||||
inkscape:cx="167.47208"
|
||||
inkscape:cy="123.18194"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="962"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="33"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer1" />
|
||||
<defs
|
||||
id="defs1" />
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1">
|
||||
<rect
|
||||
style="fill:#000080;stroke-width:0.193167"
|
||||
id="rect1"
|
||||
width="63.682735"
|
||||
height="4.0229344"
|
||||
x="4.4946499"
|
||||
y="3.4182527" />
|
||||
<rect
|
||||
style="fill:#b3b3b3;stroke-width:0.543629"
|
||||
id="rect1-5"
|
||||
width="63.682735"
|
||||
height="31.862694"
|
||||
x="4.5049582"
|
||||
y="7.3757277" />
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:1.41111px;line-height:1.25;font-family:FreeSans;-inkscape-font-specification:FreeSans;stroke-width:0.264583"
|
||||
x="25.181696"
|
||||
y="20.839491"
|
||||
id="text2"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan2"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:FreeSans;-inkscape-font-specification:FreeSans;stroke-width:0.264583"
|
||||
x="25.181696"
|
||||
y="20.839491">Menu content here</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:1.41111px;line-height:1.25;font-family:FreeSans;-inkscape-font-specification:FreeSans;fill:#ffffff;stroke-width:0.264583"
|
||||
x="6.1205692"
|
||||
y="5.8075914"
|
||||
id="text3"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan3"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:FreeSans;-inkscape-font-specification:FreeSans;fill:#ffffff;stroke-width:0.264583"
|
||||
x="6.1205692"
|
||||
y="5.8075914">Questshock (v3.0.2)</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:1.41111px;line-height:1.25;font-family:FreeSans;-inkscape-font-specification:FreeSans;fill:#ffffff;stroke-width:0.264583"
|
||||
x="60.855247"
|
||||
y="5.7559261"
|
||||
id="text4"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan4"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:FreeSans;-inkscape-font-specification:FreeSans;fill:#ffffff;stroke-width:0.264583"
|
||||
x="60.855247"
|
||||
y="5.7559261">[FPS: 70]</tspan></text>
|
||||
<rect
|
||||
style="fill:#000080;stroke-width:0.156477"
|
||||
id="rect1-2"
|
||||
width="63.682735"
|
||||
height="2.6398373"
|
||||
x="4.471632"
|
||||
y="39.1059" />
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:1.41111px;line-height:1.25;font-family:FreeSans;-inkscape-font-specification:FreeSans;fill:#ffffff;stroke-width:0.264583"
|
||||
x="5.4945946"
|
||||
y="40.859875"
|
||||
id="text3-7"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan3-5"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:FreeSans;-inkscape-font-specification:FreeSans;fill:#ffffff;stroke-width:0.264583"
|
||||
x="5.4945946"
|
||||
y="40.859875">Main Menu</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:1.41111px;line-height:1.25;font-family:FreeSans;-inkscape-font-specification:FreeSans;fill:#ffffff;stroke-width:0.264583"
|
||||
x="13.718193"
|
||||
y="40.915668"
|
||||
id="text3-7-8"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan3-5-9"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:FreeSans;-inkscape-font-specification:FreeSans;fill:#ffffff;stroke-width:0.264583"
|
||||
x="13.718193"
|
||||
y="40.915668">Video Options</tspan></text>
|
||||
<rect
|
||||
style="fill:#1a1a1a;stroke:#ffffff;stroke-width:0.256016"
|
||||
id="rect4"
|
||||
width="0.055807162"
|
||||
height="2.4157476"
|
||||
x="12.918808"
|
||||
y="39.218266" />
|
||||
<rect
|
||||
style="fill:#1a1a1a;stroke:#ffffff;stroke-width:0.256016"
|
||||
id="rect4-1"
|
||||
width="0.055807162"
|
||||
height="2.4157476"
|
||||
x="22.843752"
|
||||
y="39.203552" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.2 KiB |