17 Commits

Author SHA1 Message Date
ml 5d7564c3b5 Update the docs and add AGENTS.md
build / build (push) Successful in 2m18s
2026-09-20 15:25:33 +02:00
ml 70c43240bc Add a Cheats menu tab with engine cheat buttons and unlimited-resource switches
build / build (push) Successful in 3m42s
The new Cheats tab exposes the engine's own cheat handlers as buttons:
give all items and weapons, toggle physics, next level and previous
level. Physics shows its state through the button highlight. The buttons
need a running game and show a message otherwise.

It also has four independent session-only switches for unlimited health,
energy, ammo and items (grenades and drugs). While a switch is on, its
resource is topped up every frame by qs_cheat_refill() from the new
engine patch 26. The switches are not saved to the config.
2026-09-20 14:59:33 +02:00
ml d8324d409e Add free view and a separate cyberspace action set
build / build (push) Successful in 3m24s
- Free view: hold a mapped input (default: left grip) and that thumbstick
  drives the engine's mouse look, with the stick's normal bindings suspended
  while held. Also available for the right stick, unbound by default.
- Cyberspace actions: eleven thrust/climb/dive/bank/roll actions with their
  own bindings, active only in cyberspace. The normal movement actions are
  inactive there, and capture exclusivity only compares actions that can be
  active together, so one input can be bound in both sets.
- Cyberspace pitch and bank scale with thumbstick deflection instead of
  running at full strength.
- Remove the toggle_freelook action; bind the automap view to the right
  stick click by default.
- Engine patches: 24 exposes whether the player is in cyberspace, 25 lets
  the controller code scale cyberspace pitch and bank.
2026-09-20 07:24:44 +02:00
ml c24b7f15cb Add remappable controller controls with a Controller Options tab
build / build (push) Successful in 2m14s
Add a player_controls config group that maps game actions to controller
inputs, and a Controller Options menu tab to rebind them in-headset.

- 45 actions: movement, click/double-click, jump, fire, and the engine's own
  keyboard hotkeys (posture, save/load, weapon/MFD select, etc.), sent to the
  engine as synthetic key events. Cheat and dead hotkeys are not exposed.
- Press-to-capture rebinding with per-input exclusivity, Reset to Defaults
  (built-in defaults, then questshock.config.default.json), and new defaults:
  left stick moves/strafes, right stick turns.
- Controller profiles for Touch, Vive and generic OpenXR
  (res/controller_profiles/), matched to the active interaction profile at
  runtime, with a fatal dialog if a profile's bindings are rejected at
  startup.
- The menu's thumbstick locomotion is disabled while the Controller Options
  tab is open or a capture is active. A scrollable list with a tap-to-jump
  scroll bar handles the long action list.
- Opening the menu pauses a running game and resumes it on close.
- Reset Position now keeps the player's facing instead of turning the screen
  to the current gaze.
- Play the intro video in the game's language (engine patch 23), falling back
  to English.
- Watchdog and shared-key handling for stuck movement keys, plus key-edge
  logging.
2026-09-20 06:08:50 +02:00
ml e1e6a006d4 Add configurable game/initial screen width and distance, with a bundled first-run config
build / build (push) Successful in 2m41s
- Video Options tab offers width and distance presets independently for
  the Initial Screen (splash/intro/menu) and Game Screen (gameplay)
  profiles, with their preset step/offset configurable via
  questshock.config.json.
- Ships res/questshock.config.default.json as the initial config on
  first run (or whenever questshock.config.json doesn't exist yet),
  refreshed from the APK on every launch without touching a user's own
  saved config.
2026-09-06 09:08:54 +02:00
ml 5fe4cdf7f8 Bump AGP to 8.5.2 and Gradle to 8.7 for 16 KB page-size support
build / build (push) Successful in 2m21s
Every .so this project builds was already ELF-segment-aligned for 16 KB
pages (build-image/Dockerfile's ANDROID_16KB_LDFLAGS, app/build.gradle's
own CMAKE_SHARED_LINKER_FLAGS), but AGP 8.1.1 doesn't align native
libraries' zip entries within the APK itself - that only became automatic
in AGP 8.5.1, which is what Android Studio's "not compatible with 16 KB
devices" warning was actually flagging. No build-image changes needed:
Gradle/AGP are resolved over the network at build time, not baked into
the image.
2026-09-05 16:18:29 +02:00
ml 369555efc7 Making the menu slightly larger 2026-09-05 15:53:15 +02:00
ml 360a1f7e3d Add VR locomotion: move/turn with thumbsticks while the menu is open
build / build (push) Successful in 2m25s
- Left thumbstick snap-turns, right thumbstick smooth-moves, letting the
  player line themselves up with the fixed game quad ("virtual screen")
  without needing physical room-scale space to do it in. Implemented via
  a second OpenXR reference space (a "play space") derived from the real
  LOCAL space by the inverse of the player's transform, so every existing
  quad/hit-testing/rendering call site just needed g_local_space swapped
  for it - no manual coordinate-transform math required elsewhere
- Generalized the ray/quad hit-testing in xr_input.c to a proper
  oriented-plane intersection (needed once quads can rotate); verified it
  degenerates to the old axis-aligned formula when unrotated, so the
  keyboard and non-recentered menu are unaffected
- The menu launcher now re-centers in front of the player, correctly
  oriented to face them, each time it's freshly reopened
- "Reset Position" (Main Menu tab) recenters using the player's current
  real head pose - not a plain reset-to-origin, which only undid
  thumbstick movement and ignored physical drift since the session
  started. The same recenter runs once automatically at startup too, as a
  fallback for when there's no complete saved position to use instead
- Position/orientation persists in questshock.config.json's new
  player_position group (independent of savegames), including new
  numeric-value support in the config file's hand-rolled JSON parser
  (previously string-enums only)
2026-09-05 15:47:54 +02:00
ml c0619a55ab Add a language option to the VR menu, and fix a crash switching to it
build / build (push) Successful in 2m22s
- New "Language" row on the Main Menu tab (English/French/German),
  backed by a new "general" group in questshock.config.json
- Fixes a device-reproduced crash: switching language used to call
  language_dealfunc(), whose render_run()/opanel_redraw() repaint
  assumes the game's own in-game options panel is on screen - crashes
  (SIGSEGV) when triggered from our independent VR overlay instead.
  Now calls language_change() directly, which is all that's actually
  needed to reload resources and refresh the inventory/MFD/side-icon UI
- Fixes a second crash: this data set ships frnstrng.res/gerstrng.res
  but not mfdfrn.res/mfdger.res, and language_change() closes the
  current resource files before discovering the new ones don't exist -
  leaving no valid resource file open and crashing on the next lookup.
  Now checks both files exist before ever switching, refusing the
  change (with an in-headset popup) instead of corrupting resource state
- extract_assets.sh can now also pull mfdfrn.res/mfdger.res out of a
  System Shock: Classic installer (which includes them, unlike Enhanced
  Edition) if one is present in res/assets/ - entirely optional
- Documented all of the above in README.md/CONFIG.md
2026-09-05 08:09:12 +02:00
ml 09280dd410 Overhaul the VR menu UI: header, tabs, live video options, hover/click feedback, i18n, and dragging
build / build (push) Successful in 2m25s
- Header with title, git-tag-derived version, and a live FPS/frame-time/
  CPU/GPU-utilization/free-memory stats ribbon below the tab bar (FPS via
  a new per-frame tracker in xr_session.c; CPU/GPU via the OpenXR
  XR_META_performance_metrics extension, gracefully unavailable on
  runtimes that don't support it)
- Bottom tab bar with a live-editable Video Options tab (renderer/
  resolution), applying changes immediately via qs_config.c
- Hover and press feedback on every menu/keyboard button via a
  StateListDrawable (Android's default ripple never animates on this
  permanently unattached view tree), with a lighter default button color
  for visibility
- i18n for all menu/keyboard chrome text, following the game's own
  language setting (Strings.java)
- The main menu is now draggable via its header, matching the keyboard;
  xr_input.c's drag handling is generalized to per-overlay state instead
  of being keyboard-only
- The keyboard's title bar now matches the menu header's style
2026-09-05 07:02:30 +02:00
ml c4fd4eb3dd Adding support for a configuration file 2026-09-05 05:30:00 +02:00
ml e0a2e1f4bf Fix persistent GL_INVALID_OPERATION spam and resolution-change bugs on Android
build / build (push) Successful in 2m44s
- opengl_end_frame() bound framebuffer 0 on Android, which has no valid
  target there (real presentation goes through the OpenXR swapchain's own
  FBOs) - gl4es reported this every frame. Skip it there.
- render_hack_cameras() re-entered opengl_start_frame()/opengl_end_frame()
  for camera-monitor renders that never touch the GL screen; excluded them
  to match use_opengl()'s existing behavior. Also drains a stray error left
  by xrPollEvent().
- ChangeScreenSize() never called opengl_resize() on Android, freezing the
  3D render target at launch size after an in-game resolution change; and
  xr_get_game_resolution() used the swapchain's fixed size instead of the
  current screen resolution, misplacing the laser-pointer cursor after such
  a change.
- Removes all temporary bisection diagnostics added while tracking down the
  GL_INVALID_OPERATION source.
2026-09-04 16:34:52 +02:00
ml 504ed3e1cb Fix SFX and MIDI music not playing on Android
snd_start_digital() opened two separate SDL audio devices - a raw one
for cutscene/audiolog audio and a second via Mix_OpenAudio() for
SFX/MIDI - but Android's audio backends only ever allow one open
non-capture device, so the second open always failed silently.

- Consolidate onto a single Mix_OpenAudio() device; cutscene/audiolog
  PCM is now mixed into its output via Mix_SetPostMix() instead of a
  dedicated device (16-android-audio-single-device.patch)
- Add SDL_GetError() to the failure log so a future open failure is
  actually diagnosable (15-android-audio-error-detail.patch)
- Make NativeMidiInit() report failure on platforms with no real
  implementation (Android included), instead of silently claiming
  success without opening anything (17-android-native-midi-unsupported.patch)
- Strengthen the OpenSL ES driver hint to SDL_HINT_OVERRIDE and log the
  active audio driver as a standing diagnostic (05-android-audio-driver.patch)
2026-08-30 07:09:16 +02:00
ml d6e438a541 Fix 3D texture corruption and stuck/dead-key input bugs
build / build (push) Successful in 2m13s
Stale GL_UNPACK state in gl4es and a missing pre-release GPU sync
caused diagonal-banded corruption in the 3D view. Synthetic key
press/release must track the real controller button or touch down/up
edges, not fire together in one call - fixes a typed movement key
sticking forever, then a follow-up that had silently broken A-button
jump and on-screen keyboard arrow keys. Also adds force-rebuild.sh for
the known stageEngine stale-cache issue.
2026-08-29 19:26:53 +02:00
matthias a2fb95e57b Add Windows cross-compilation via MinGW, and fix Docker build permission
build / build (push) Successful in 3m5s
issues on vboxsf-mounted checkouts

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

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

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

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

Only claim an overlay when the ray is actually relevant this frame - a
real hit, or a touch/drag begun on a previous frame still pending
release - so a visible-but-unaimed-at overlay now falls through to the
next-priority target. Give the game quad its own hit-test
(xr_input_try_game_quad()) and the same billboarded laser-beam
treatment as the overlays instead of the old flat reticle, so aiming
looks and behaves identically everywhere - overlay open or not. The
old reticle GL program/shaders are now fully dead and removed. No
click/touch dispatch is added to the game quad itself; that's future
work.
2026-08-14 07:59:09 +02:00
72 changed files with 10115 additions and 532 deletions
+2
View File
@@ -0,0 +1,2 @@
* text=auto
*.sh text eol=lf
+19 -10
View File
@@ -1,14 +1,16 @@
name: build
# Builds a versioned Linux release tarball (see `make package`) on every
# push/PR, plus on-demand via the Gitea "Run workflow" button. Runs
# Builds versioned Linux and Windows release packages (see `make
# package`, which builds both - a .tar.gz and a .zip) on every push/PR,
# plus on-demand via the Gitea "Run workflow" button. Runs
# inside the build-image (see ../../build-image/Dockerfile, built/pushed
# via ../../build-image.sh and ../../upload-image.sh), which bundles
# every dependency engine/ needs to compile - no network access needed
# at job runtime. Since the job's container already *is* the build-image
# (QUESTSHOCK_BUILD_IMAGE=1), `make package`'s `engine` prerequisite
# compiles directly instead of trying to docker-run it again, which
# wouldn't work here (no nested docker).
# every dependency engine/ needs to compile (for both platforms,
# including the Windows cross-toolchain) - no network access needed at
# job runtime. Since the job's container already *is* the build-image
# (QUESTSHOCK_BUILD_IMAGE=1), `make package`'s `engine`/`engine-win`
# prerequisites compile directly instead of trying to docker-run it
# again, which wouldn't work here (no nested docker).
on:
push:
pull_request:
@@ -50,17 +52,22 @@ jobs:
# comment) - openxr was added to this image in the same
# Android/Quest layer, so it's an equally good marker of that.
[ -d /opt/prebuilt/android/openxr ] || { echo "PREFLIGHT FAIL: /opt/prebuilt/android/openxr missing - runner is using a build-image older than the Android/Quest layer" >&2; exit 1; }
[ -d /opt/prebuilt/win/sdl2 ] || { echo "PREFLIGHT FAIL: /opt/prebuilt/win/sdl2 missing - runner is using a build-image older than the Windows cross-build layer" >&2; exit 1; }
command -v x86_64-w64-mingw32-gcc >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: x86_64-w64-mingw32-gcc not in PATH" >&2; exit 1; }
command -v zip >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: zip not in PATH (needed by make package-win)" >&2; exit 1; }
- name: Build package
run: make package
- name: Upload build artifact
- name: Upload build artifacts
# v4 uses the newer @actions/artifact backend, which this Gitea
# instance's artifact storage doesn't support (GHESNotSupportedError) - v3 works.
uses: actions/upload-artifact@v3
with:
name: shockolate
path: dist/shockolate-*-linux-*.tar.gz
path: |
dist/shockolate-*-linux-*.tar.gz
dist/shockolate-*-windows-*.zip
- name: Build Quest APK
# QUESTSHOCK_BUILD_IMAGE is already set (see Preflight above), so
@@ -78,13 +85,14 @@ jobs:
path: dist/questshock-*-android-*.apk
- name: Publish to dl.ladkau.de
# Uploads the tarball and APK over SFTP instead of using
# Uploads the tarball, zip, and APK over SFTP instead of using
# actions/upload-artifact (whose zip wrapping can't be disabled).
# Only runs on push so PR builds don't publish.
if: gitea.event_name == 'push'
run: |
set -euo pipefail
TARBALL="$(ls dist/shockolate-*-linux-*.tar.gz)"
ZIP="$(ls dist/shockolate-*-windows-*.zip)"
APK="$(ls dist/questshock-*-android-*.apk)"
mkdir -p ~/.ssh
echo "${{ secrets.DL_SFTP_KEY }}" > ~/.ssh/dl_sftp_key
@@ -94,5 +102,6 @@ jobs:
uploader@dl.ladkau.de <<EOF
-mkdir files/questshock
put $TARBALL files/questshock/$(basename "$TARBALL")
put $ZIP files/questshock/$(basename "$ZIP")
put $APK files/questshock/$(basename "$APK")
EOF
+7 -1
View File
@@ -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
+373
View File
@@ -0,0 +1,373 @@
# AGENTS.md
Guidance for AI coding agents (and new contributors) working on Questshock.
README.md is the user-facing build/usage guide and CONFIG.md documents the
configuration file; this file covers how the code is organised and the rules to
follow when changing it.
## 1. What this project is
Questshock plays the 1994 game System Shock on a standalone VR headset (Meta
Quest 2 is the tested device). It wraps [Shockolate](https://github.com/Interrupt/systemshock),
a cross-platform port of the original engine, in an Android app that:
- runs the unmodified 2D engine (software or OpenGL renderer) and shows its
frame on a head-tracked virtual screen (an OpenXR quad layer);
- shows a menu and an on-screen keyboard as separate OpenXR quads, operated by
a laser pointer from either controller;
- maps controller inputs to game actions through a configurable binding layer;
- persists settings in `/sdcard/questshock/questshock.config.json`.
A desktop (Linux/Windows) build of the same engine exists for development
without a headset; it has none of the Android/OpenXR code.
## 2. Hard rules
These come from the maintainer. Follow them without being asked again.
1. **Never commit.** Do not run `git commit` (or amend, rebase, push), even if
asked to commit. Draft the commit message and show it; the maintainer runs
the commit. Mention any new untracked file that has to be `git add`ed
(typically a new patch under `android/engine-patches/`).
2. **Do not build.** Do not run `make apk`, `./gradlew`, or `make android-studio`
to check that a change compiles. The maintainer deploys from Android Studio.
`make apk` and `make android-studio` overwrite `android/engine.properties`
with container-only paths and break an open Android Studio project. What you
can do is dry-run patches (section 6) and run static consistency checks
(section 12). Say plainly in your report that the change was not built or
tested.
3. **`engine/` and `android/gl4es-src/` are never edited.** They are vendored
snapshots. Every change to them is a numbered patch under
`android/engine-patches/` or `android/gl4es-patches/`, applied to a scratch
copy at build time. (`engine/` is GPLv3, see README section 6.)
4. **Patch numbers stay contiguous** (`01`, `02`, ... with no gap). When you
insert or delete a patch, renumber the rest and update every reference to
the numbers (README patch reference, code comments).
5. **Comments and documentation describe the current state and why it has to be
that way.** No history or narrative: no "used to", "originally", "no
longer", "now", "this was fixed", "confirmed on device", incident stories or
dropped alternatives. History belongs in commit messages. A comment that
would read oddly to someone who never saw an earlier version should be
rewritten or cut. Default to one or two lines. (README section 5.5, the
debugging notes, is the one deliberate exception.)
6. **Standalone headset only, OpenXR first.** No feature may need a host PC
(no PCVR, tethering or streaming). Prefer OpenXR APIs over vendor-specific
ones (Meta's Oculus Mobile SDK and the like) so the port can run on other
Android OpenXR headsets. Flag a request that would break either rule
instead of quietly doing it.
7. **Do not bump `build-image/VERSION`** just because `build-image/Dockerfile`
changed. Bump it only once a build with the current number has succeeded
end to end (for example a CI run). Ask if unsure.
8. **Keep docs in sync with behaviour.** A user-visible change updates
README.md and/or CONFIG.md in the same change; a new or renumbered engine
patch updates the README patch reference (section 5.4).
## 3. Repository layout
| Path | What it is |
|---|---|
| `engine/` | Vendored Shockolate source. Read-only for us. |
| `android/engine-patches/` | Numbered `patch -p1` files applied to a scratch copy of `engine/`. |
| `android/gl4es-src/`, `android/gl4es-patches/` | Vendored GL4ES (desktop GL to GLES) and its patches. |
| `android/app/src/main/cpp/` | First-party native code (section 4). |
| `android/app/src/main/java/de/ladkau/questshock/` | First-party Java (section 4). `org/libsdl/app/` is vendored SDL2 glue (zlib). |
| `android/app/build.gradle` | Gradle project; the `stageEngine` task stages the patched engine. |
| `android/force-rebuild.sh` | Stops Gradle daemons and wipes staged/build output for a fully fresh native build. |
| `build-image/` | Docker build environment (`Dockerfile`, `VERSION`, `prepare-android-project.sh`, `build-apk.sh`, engine build scripts). |
| `res/` | Bundled resources: `questshock.config.default.json`, `controller_profiles/` (JSON + PNG per controller), `assets/` (where the user's own game data goes, git-ignored), `run.sh`/`run.bat`. |
| `Makefile` | `engine`, `dist`, `package`, `apk`, `android-studio`, `clean`, ... (see README section 2). |
| `README.md`, `CONFIG.md`, `NOTICE.txt` | User docs, config reference, license notice. |
`build/`, `dist/`, `dist-win/` and `res/assets/ss_ee/` are generated or
proprietary and git-ignored. `registry.env` holds local registry settings; do
not print it or commit it.
## 4. Android app architecture
### 4.1 Native modules (`android/app/src/main/cpp/`)
| File | Role |
|---|---|
| `xr_session.c/.h` | OpenXR instance and session lifecycle, frame loop, composition layers (game quad, menu, keyboard, laser beams). |
| `xr_swapchain.c/.h` | Swapchain creation, acquire and release, per-image FBOs. |
| `xr_overlay.c/.h` | Generic "off-screen Android `View` rendered into an OpenXR quad" module, hit-tested by a laser pointer. The menu and the keyboard are two instances. |
| `xr_input.c/.h` | Controller input: OpenXR actions, raw-input layer, action bindings, capture-mode rebinding, key injection into the engine, free view, cyberspace scaling, snap-turn/smooth-move of the player's position, pointer rays. The largest file. |
| `xr_mouse.c/.h` | Injects mouse motion and clicks into the engine. |
| `qs_config.c/.h` | Configuration: JSON parse, apply, write; screen profiles; menu-driven requests; cheats; menu-triggered pause. |
| `questshock_native.c` | JNI exports and small native helpers (`nativeChdir`, `nativeSendPrintableChar`, ...). |
### 4.2 Java classes
| Class | Role |
|---|---|
| `QuestShockActivity` | `SDLActivity` subclass. Creates `/sdcard/questshock/`, extracts bundled files, blocks engine start until the game data exists. |
| `OverlayPanel` | Base for the off-screen panels: builds the view tree, renders it to a texture, turns ray hits into touch events. |
| `MenuOverlay` | The menu: Main Menu, Video Options, Controller Options and Cheats tabs. Singleton. |
| `KeyboardOverlay` | The on-screen keyboard. |
| `Strings` | All menu and keyboard text in English, French and German. |
### 4.3 How the pieces fit
- The engine's present path is redirected (engine patch 11) so that
`SDLDraw()` submits into the OpenXR game-quad swapchain. The per-frame
entry point of `xr_input.c` is `xr_input_sync_and_draw()`, called from the
frame path in `xr_session.c` on the engine's own thread.
- The engine's own log output goes to logcat (patch 08); Java and native
first-party code log under the tag `QuestShock`.
- Bindings, config and menu state are owned natively. Java only displays and
requests.
### 4.4 Threads and cross-thread requests
- Engine state (player struct, game loop, cheats, config apply) must only be
touched on the engine thread.
- Menu code calls native functions through JNI. Anything that changes engine
state must be enqueued, not executed inline: the JNI export calls a
`qs_config_request_*()` function, which pushes an SDL custom event
(`SDL_RegisterEvents`); `qs_config_handle_event()` handles it on the engine
thread (it is called from `pump_events()`, engine patch 21). To add a new
request, add an event type, the `qs_config_request_*()` enqueue function and
a branch in `qs_config_handle_event()`.
- Getters that Java calls (`qs_config_cheat_unlimited_mask()`, binding-name
getters, ...) must be plain scalar or string reads that are safe from any
thread.
- Per-frame work that must run on the engine thread (such as
`qs_config_frame_tick()`) is called from `xr_input.c`.
- JNI symbol names must match the Java `native` declarations exactly
(`Java_de_ladkau_questshock_<Class>_<method>`). A mismatch only fails at
runtime, so check it (section 12).
### 4.5 Off-screen views (`OverlayPanel`, `MenuOverlay`, `KeyboardOverlay`)
The view tree is built and drawn from native code and is never attached to a
window. Consequences that shape the UI code:
- Only `ACTION_DOWN`/`ACTION_UP` touches arrive (the laser trigger). There is no
drag, fling or multi-touch. Scrolling is done by a self-drawn scroll bar
(`ScrollBarView` in `MenuOverlay`), not by the framework.
- `View.post()` never runs, so anything that defers work through it does not
work. `ProgressBar` and `SeekBar` are unusable for the same reason.
- Hover and hit-testing use `OverlayPanel.absoluteBounds()`, which subtracts
ancestor scroll offsets.
- Toggling a child's visibility needs an explicit re-measure and re-layout
(see `relayoutAndRedraw()`).
- All user-visible text goes through `Strings.get(key, language)` with an
English, French and German entry, and is re-applied in `applyTranslations()`
when the language changes. "OpenGL" is a brand name and is not translated.
## 5. Configuration system
- `qs_config.c` holds one parsed struct per group (`video_options`, `general`,
`player_position`, `player_controls`), a parse function, an apply function,
and the writer `qs_config_write_file()`. Groups are tagged live-appliable or
restart-required (`QS_*_REQUIRES_RESTART`).
- Load order at startup: `questshock.config.json`, else
`questshock.config.default.json` (bundled, refreshed on every launch), else
the engine's own `prefs.txt` behaviour. Save is only ever an explicit menu
action.
- Invalid values are logged and skipped individually; unknown keys are ignored.
**Adding an option:** parsed-struct field, parse, apply (with validation and a
`WARN` on invalid input), write in `qs_config_write_file()`, restart flag,
`CONFIG.md`, and (if it should have a shipped default) `res/questshock.config.default.json`.
The virtual screen has two profiles, "initial" (splash, cutscenes, main menu)
and "game", switched by `qs_screen_profile_notify_loop_mode()` from engine
patch 22. `s_screen_profile_is_game` in `qs_config.c` doubles as "a game is
running" and gates the cheats and the menu pause.
## 6. Engine patches
Rules:
- Guard every engine change with `#ifdef __ANDROID__` so the desktop build is
unaffected, and keep hunks minimal.
- Patches are applied in filename order with `patch -p1` from the engine root
(paths look like `a/src/GameSrc/hkeyfunc.c`). Patch N is generated against
the tree with patches `01..N-1` applied.
- Some engine files use CRLF line endings (for example `mouselook.c`). Edits
and generated patches must preserve them byte for byte; check with `file`
or `cat -A`. An editor or script that rewrites line endings produces a
whole-file diff.
- Do not hand-edit hunk headers. Regenerate the patch from a scratch copy.
Creating or changing a patch:
1. Make a scratch copy of `engine/` (for example under the session scratch
directory) and apply patches `01..N-1` to it (`patch -p1 -d <scratch> < ...`).
2. Copy the target file(s) to a second location, edit them in the scratch copy.
3. Produce the patch with `diff -u` using `a/` and `b/` path prefixes relative
to the engine root, and save it as `android/engine-patches/NN-android-<topic>.patch`.
4. Dry-run the whole series on a fresh copy of `engine/`
(`patch -p1 --dry-run`, or apply for real on the copy) and confirm every
patch applies cleanly, with the patched file byte-identical to what you
edited.
5. Keep numbering contiguous, and update the README patch reference (5.4) and
any code comments that cite patch numbers.
Gradle's `stageEngine` task re-stages and re-patches on build, but it can wrongly
report `UP-TO-DATE` when a **new** patch file appears. If a native change does
not show up on the device, tell the maintainer to run `./gradlew --stop`, or
`android/force-rebuild.sh`, or delete `build/android-engine`, before assuming
the change is wrong.
Engine symbols used from first-party code (for example `game_paused`,
`pause_game_func`, `toggle_giveall_func`, `qs_cheat_refill`,
`qs_in_cyberspace`) are declared `extern` next to their use in `qs_config.c` or
`xr_input.c`, with a comment naming the engine file and patch.
## 7. Controller mapping (`xr_input.c`)
Structure:
- Physical controls are OpenXR actions, suggested once against three
interaction profiles: Meta Touch, HTC Vive wand and the core
`khr/simple_controller`. A single wrong path makes the whole
`xrSuggestInteractionProfileBindings` call fail, so each profile lists only
components it really has. A failure calls
`qs_config_report_fatal_input_error()` and aborts input init.
- The **raw-input layer** (`QsRawInput`) digitizes every physical control
(buttons, triggers, squeezes, thumbstick clicks, and eight thumbstick
directions with hysteresis) each frame. `QS_RAW_NONE` means unbound.
- **Actions** (`QsControlAction`) are bound to raw inputs through `s_binding[]`.
The default bindings are `kDefaults[]`.
- Contexts: `ALWAYS`, `NORMAL` (move, strafe, turn, jump, stand, crouch, prone)
and `CYBER` (the eleven `cyber_*` actions). The active set is chosen with
`qs_in_cyberspace()` (patch 24). Two actions conflict only if their contexts
can be active together (`xr_input_contexts_overlap()`).
- Capture mode (`xr_input_process_capture()`): the next fresh raw down-edge is
assigned to the chosen action. Any other action in an overlapping context that
held that input (or its symmetric alias) is set to `none`. The reset and the
timeout (8 s) paths never leave a half-assigned state.
- The menu button is not a raw input and can never be bound, so the menu can
always be reached to press **Reset to Defaults**.
- While the menu is open, `mouse_click` is always the trigger (the controller
used to operate the menu); after it closes, the bound `mouse_click` drives
both the keyboard and the game quad.
- While the Controller Options tab is open, the menu's own thumbstick
snap-turn and smooth-move are disabled
(`g_controller_options_tab_open`, set through
`nativeSetControllerOptionsTabOpen`).
- Free view: holding a `free_*_thumbstick_view` binding turns that stick into
the engine's mouse look (cursor forced hidden, stick direction bindings
suspended). It uses `SDL_ShowCursor(SDL_DISABLE)` because the engine zeroes
mouse-look velocity while the cursor is visible.
- Cyberspace pitch and bank scale with stick deflection through the globals
from patch 25 (`qs_cyber_pitch_scale`, `qs_cyber_bank_scale`).
### Adding or changing a bindable action (checklist)
All of these lists share one order and must stay identical:
1. `QsControlAction` enum in `xr_input.c` (and append before `QS_ACTION_COUNT`).
2. `kActionNames[]` in `xr_input.c`.
3. `s_binding[]` initial values and `kDefaults[]` in `xr_input.c`.
4. If it injects a key: a `kKeyActions[]` entry (keycode, modifiers, whether it
needs `SDL_TEXTINPUT`); add the key to the stuck-key watchdog list
(`kWatchedKeys`) if it is held continuously.
5. `ACTION_KEYS` in `MenuOverlay.java`, and `FIRST_ROW_ACTIONS` if it should be
listed first.
6. `Strings.java`: `action_<key>` in EN/FR/DE.
7. `QS_MAX_PLAYER_CONTROL_ACTIONS` in `qs_config.c` if the count outgrows it.
8. `CONFIG.md`: the action tables and the count in the Player Controls intro;
README default-controls table if it has a default binding.
9. Check that no two actions that can be active together share a default input.
### Injecting keys into the engine
- Plain printable keys (letters, digits, Space, punctuation) need an
`SDL_TEXTINPUT` event plus `SDL_KEYDOWN`/`SDL_KEYUP`. Modifier, control-range,
F and keypad keys use `SDL_KEYDOWN`/`SDL_KEYUP` with the right `mod`.
- **Send the release on the real up edge, never in the same call as the
press.** The engine polls movement keys once per frame before it drains
events, so a press and release drained together are never seen and the key
silently does nothing. Tie the release to the physical button's up edge (or
the touch's `ACTION_UP`/`ACTION_CANCEL`).
- Held keys are tracked in the engine's `sshockKeyStates[]`; `xr_input.c`
keeps a watchdog that clears a watched key the engine still thinks is down
after its binding is released.
- Actions that share an engine key (for example the several ways to fire, or
the movement keys) are handled with sibling logic in the dispatch loop
(`wantDown[]`); do not press or release the shared key independently.
## 8. Cheats tab
- Buttons and switches live in `MenuOverlay.buildCheatsTab()`; ids are
`QS_CHEAT_*` in `qs_config.c` and `CHEAT_*` in `MenuOverlay.java` (keep them
identical).
- The engine's own cheat handlers (`toggle_giveall_func`, `toggle_physics_func`,
`toggle_up_level_func`, `toggle_down_level_func`) are called on the engine
thread through `qs_apply_cheat()`, and only while a game is running.
- The four unlimited switches are a bitmask in `qs_config.c`
(`QS_CHEAT_MASK_*`: health 1, energy 2, ammo 4, items 8). Every frame
`qs_config_frame_tick()` calls `qs_cheat_refill(mask)` (patch 26). The mask
values must match the patch. The switches are not persisted.
- The four `cheat_*` hotkeys are deliberately not bindable in
`player_controls`. New cheats go on the Cheats tab.
## 9. Menu pause
Opening the menu pauses a running game (`qs_apply_menu_pause()` through the
menu-visibility event); closing it resumes only if the menu did the pausing and
the game is still paused. Do not toggle pause blindly, since the pause function
toggles.
## 10. Bundled files on the device
`QuestShockActivity` extracts these into `/sdcard/questshock/`:
- shaders, the default soundfont and `GET_ASSETS_QUEST.txt` (once);
- `questshock.config.default.json` and `controller_profiles/` (every launch,
overwriting only the bundled filenames so user-added profiles survive).
`res/controller_profiles/` and the default config are staged into APK assets by
`build-image/prepare-android-project.sh`; new staged directories must also be
declared as `inputs.dir(...)` of `stageEngine` in `android/app/build.gradle`.
The proprietary game data (`res/data/`, `res/sound/`) is never bundled or
committed. The app refuses to start the engine until it is present.
## 11. Code and documentation style
- Match the surrounding code: naming, comment density, idiom. C is plain C with
`static` helpers and `//` comments; Java targets Android API 34 and uses only the SDK plus
`androidx.core`.
- Keep functions and file-level constants near their use. Put magic numbers in
named constants (see `QS_FREE_VIEW_*`, `QS_CYBER_ROTATION_*`).
- Keep the `xr_input.h` and `qs_config.h` declarations documented with a short
comment saying which thread may call them.
- Comments: what and why, current state only (rule 5 in section 2).
- Docs: README.md is organised by numbered sections that other text cites
("5.5.3"); do not renumber existing sections. Add new material as a new
subsection instead.
- English, French and German strings live in `Strings.java`; keep the three
entries of each key on the same `put(...)`.
## 12. Verifying a change without building
Because you may not build (rule 2), check statically and say so in the report:
- **Patches:** apply the whole series to a fresh copy of `engine/` and confirm
no rejects. Confirm the edited file matches the patched result byte for byte.
- **JNI:** every `native` method in the Java classes has a
`Java_de_ladkau_questshock_<Class>_<method>` export with a matching signature,
and no export is left without a Java declaration.
- **Ordered lists:** `QsControlAction`, `kActionNames[]`, `s_binding[]`,
`kDefaults[]` and `ACTION_KEYS` have the same length and order.
- **Strings:** every `Strings.get("...")` key exists in `Strings.java`.
- **Balance:** braces and parentheses balance. `qs_config.c` reports a
brace-count offset of +5 from character literals containing `'{'`; that is
not a bug.
- **Docs:** README and CONFIG.md agree with the code (action counts, default
bindings, patch list).
Then report what was checked, what was not (build, on-device behaviour), and
any device check the maintainer should do (for example a new patch that may
need `android/force-rebuild.sh`).
## 13. Commit messages
Draft only (rule 1). Use a short imperative subject (about 70 characters), a
blank line, then a body that explains what the change does and why: the
user-visible behaviour, the mechanism, and any constraint worth remembering.
Mention new files that need `git add`.
+458
View File
@@ -0,0 +1,458 @@
# 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, `questshock.config.default.json` (same
folder) is used instead - a copy bundled with the app, refreshed on every
launch, never overwriting your own saved `questshock.config.json`. If
neither file exists, the engine behaves 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",
"initial_screen_width_m": 1.6,
"initial_screen_distance_m": 2.0,
"game_screen_width_m": 2.8,
"game_screen_distance_m": 2.0,
"screen_width_offset_m": 1.0,
"screen_width_step_m": 0.6,
"screen_distance_offset_m": 1.0,
"screen_distance_step_m": 0.5
},
"general": {
"language": "english"
},
"player_position": {
"x": 0.0,
"z": 0.0,
"yaw_degrees": 0.0
},
"player_controls": {
"move_forward": "left_thumbstick_up",
"move_back": "left_thumbstick_down",
"strafe_left": "left_thumbstick_left",
"strafe_right": "left_thumbstick_right",
"turn_left": "right_thumbstick_left",
"turn_right": "right_thumbstick_right",
"jump": "left_thumbstick_click",
"mouse_click": "right_a",
"mouse_double_click": "right_b",
"stand": "right_thumbstick_up",
"prone": "right_thumbstick_down",
"map_view": "right_thumbstick_click",
"fire_weapon": "right_trigger"
}
}
```
## 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)
### `initial_screen_width_m`, `initial_screen_distance_m`, `game_screen_width_m`, `game_screen_distance_m`
The virtual screen's physical width and distance from the player (both in
meters) - the game shows two independently configurable screen
"profiles", switched automatically based on what's currently on screen:
- **Initial Screen** (`initial_screen_*`) - the splash screen, intro
cutscenes, and the game's own main menu.
- **Game Screen** (`game_screen_*`) - actual gameplay (the 3D view,
automap, cyberspace, etc.).
Switching happens automatically - no player action needed - the instant
the game itself transitions between these (e.g. starting/loading a game,
or returning to the menu).
- **Type**: number (meters)
- **Possible values**: the in-headset Video Options tab offers 5 presets
each - widths `1.0`, `1.6` (the original, pre-this-feature default),
`2.2`, `2.8`, `3.4`; distances `1.0`, `1.5`, `2.0` (the original,
pre-this-feature default), `2.5`, `3.0` - but a hand-edited value
doesn't need to match a preset exactly (it's clamped to a sane range -
width `[0.2, 10.0]`, distance `[0.5, 10.0]` - rather than snapped or
rejected; the Video Options tab just shows no button selected for an
in-between value).
- **Restart required?**: No (live-appliable)
- **Note**: the screen is always flat - there's no curve option.
### `screen_width_offset_m`, `screen_width_step_m`, `screen_distance_offset_m`, `screen_distance_step_m`
Reshapes the 5 preset buttons the Video Options tab offers for width and
distance (above) - config-file-only, there's no in-headset control for
these. Shared by both **Initial Screen** and **Game Screen** (both
columns' buttons show the same 5 values per row) - this only changes
what the buttons offer, not either profile's current value.
Preset button `index` (0-4) shows `offset + step * index`. For example,
a distance offset of `0` and step of `2` shows buttons `0`, `2`, `4`,
`6`, `8`; the defaults below reproduce this feature's original launch
presets exactly.
- **Type**: number (meters)
- **Default values**: width offset `1.0`, step `0.6` (buttons `1.0`,
`1.6`, `2.2`, `2.8`, `3.4`); distance offset `1.0`, step `0.5` (buttons
`1.0`, `1.5`, `2.0`, `2.5`, `3.0`).
- **Restart required?**: No (live-appliable)
- **Note**: not clamped - an unusual offset/step (e.g. negative step) is
accepted as-is. A resulting preset value outside the current value's
own clamp range (see above) still clamps normally when that preset
button is actually pressed.
## 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.
- The intro cutscene is also played in the selected language when its
voice-over file (`svfrintr.res`/`svgeintr.res` under `res/data/`) is
present; otherwise the English intro plays.
## 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 live-only action, not a config
value: it moves you to the centre in front of the game quad, based on where
your head actually is right now. It resets only your *position*, not which
way you're facing, so the screen does not rotate to follow your gaze - press
it while looking off to one side and the screen stays off to the other side.
The automatic recenter at startup (when there's no saved position) does
face the screen towards where you're looking. Reset Position never reads or
writes `questshock.config.json`.
### `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)
## Player Controls (`player_controls`)
Maps each of 57 in-game actions to a physical controller input: 9
movement/click actions, 2 free-view actions, 11 cyberspace actions, and 35
for the engine's own keyboard hotkeys and firing (posture, save/load, weapon
select, MFD panels, etc. - see the hotkey table below). Some actions are bound by default (see the default-input
columns); the rest are unbound and can be bound on demand. Assign them in the
**Controller Options** menu tab: tap an action's binding button, then press
the button or stick direction to use ("Press a button…"); tap it again, or
wait 8 seconds, to cancel without changing it.
- **Type**: one enum-string field per action (below)
- **Restart required?**: No (live-appliable) - a rebind takes effect
immediately, and persists across restarts once saved (**Save
Configuration**, same as every other group here).
- **Menu movement**: while the Controller Options tab is open, the menu's own
thumbstick snap-turn and smooth-move (see `player_position`) are disabled, so
pressing inputs to assign them doesn't move you away from the screen.
- **Not included here**: opening/closing the VR menu itself is a fixed,
non-remappable button (see "Menu button" below) - there's no
`menu_toggle` field, and the Controller Options tab never lets you
reassign it. This guarantees you can always reach this tab and press
**Reset to Defaults** to recover from any bad rebind, no matter what else
you've reassigned.
- **Reset to Defaults**: restores the "Default input" column below for
every action not overridden by a `player_controls` group in
`questshock.config.default.json` - if that file defines one (it doesn't,
out of the box), the values it specifies are applied on top of the table
below instead, so you can customize what "reset" means without a rebuild.
This is independent of your own `questshock.config.json` - resetting
doesn't read or write that file, just the live bindings (**Save
Configuration** afterward to persist them).
### Actions
| Action key | Default input | Effect |
|---|---|---|
| `move_forward` | `left_thumbstick_up` | Walk forward |
| `move_back` | `left_thumbstick_down` | Walk backward |
| `strafe_left` | `left_thumbstick_left` | Step sideways, left |
| `strafe_right` | `left_thumbstick_right` | Step sideways, right |
| `turn_left` | `right_thumbstick_left` | Turn left |
| `turn_right` | `right_thumbstick_right` | Turn right |
| `jump` | `left_thumbstick_click` | Jump |
| `mouse_click` | `right_a` | Click - operates the keyboard/game quad's cursor, whichever the pointing hand's ray is over |
| `mouse_double_click` | `right_b` | Double-click - game quad only (e.g. using an inventory item); has no effect over the menu or keyboard |
| `free_left_thumbstick_view` | `left_squeeze` | Hold to look around with the left thumbstick (see below) |
| `free_right_thumbstick_view` | `none` | Hold to look around with the right thumbstick (see below) |
Movement actions bound to a thumbstick direction are suppressed while the
VR menu is open, since the same physical stick already snap-turns/smooth-
moves the player in that state (see `player_position` above) - this is the
one case where a binding's effect depends on whether the menu is open, and
it only ever affects the 8 thumbstick-direction inputs below.
**Free view** (`free_left_thumbstick_view`, `free_right_thumbstick_view`) is
the engine's mouse look driven by a thumbstick. While the bound input is held,
that stick looks around instead of doing its normal job: left/right turns the
view, up/down looks up and down, in proportion to how far the stick is pushed.
The stick's own direction bindings (move, strafe, turn, stand/prone) are
suspended while it is held, and the cursor stays centred in the view. Release
the input to return to normal. It has no effect while the VR menu is open.
### Cyberspace actions
Cyberspace has its own set of movement actions, active only while the player
is in cyberspace. While in cyberspace the normal movement actions
(`move_*`, `strafe_*`, `turn_*`, `jump`, `stand`, `crouch`, `prone`) are
inactive, and the cyberspace actions are inactive everywhere else. Each set
has its own bindings, so one input can be bound in both (for example the left
stick up is `move_forward` on foot and `cyber_climb` in cyberspace);
the Controller Options tab only reports a conflict between actions that can
be active at the same time. Free view, clicking and firing work in both.
Pitch and bank (`cyber_climb`, `cyber_dive`, `cyber_bank_*` and the four
diagonals) are proportional when bound to a thumbstick direction: pushing the
stick further turns faster, from 20% of full speed just past the trigger point
to 60% at full deflection. Bound to a button they run at full speed. Thrust and
roll are always full speed.
| Action key | Default input | Keyboard equivalent | Effect |
|---|---|---|---|
| `cyber_thrust` | `right_thumbstick_up` | `s` | Thrust forward |
| `cyber_climb` | `left_thumbstick_up` | `w` | Pitch up |
| `cyber_dive` | `left_thumbstick_down` | `x` | Pitch down |
| `cyber_bank_left` | `left_thumbstick_left` | `a` | Bank left |
| `cyber_bank_right` | `left_thumbstick_right` | `d` | Bank right |
| `cyber_roll_left` | `right_thumbstick_left` | `e` | Roll left |
| `cyber_roll_right` | `right_thumbstick_right` | `q` | Roll right |
| `cyber_climb_left` | `none` | Keypad 7 | Pitch up and bank left |
| `cyber_climb_right` | `none` | Keypad 9 | Pitch up and bank right |
| `cyber_dive_left` | `none` | Keypad 1 | Pitch down and bank left |
| `cyber_dive_right` | `none` | Keypad 3 | Pitch down and bank right |
`mouse_click` additionally behaves differently depending on the menu:
while the VR menu is open, its "click" is always whichever hand's trigger
is used to interact with the menu (so you can never lock yourself out of
this settings screen by rebinding `mouse_click` elsewhere); once the menu
is closed, both the on-screen keyboard (if it's still open) and the game
quad use your actual `mouse_click` binding. The default (`right_a`) is a
specific button on the right controller, so only the right hand's ray clicks;
bind the `trigger` identifier (see below) for either hand to click.
### Hotkey actions
The engine's own keyboard hotkeys (`HotKeyLookup[]` in `Prefs.c`) plus
firing, as 35 bindable actions. `stand` (`right_thumbstick_up`), `prone`
(`right_thumbstick_down`), `map_view` (`right_thumbstick_click`) and
`fire_weapon` (`right_trigger`) are bound by default; the rest are unbound
(`none`). Each fires once per press, like pressing the key on a real
keyboard (there is no hold behavior).
| Action key | Keyboard equivalent | Effect |
|---|---|---|
| `stand` | `t` | Stand up |
| `crouch` | `g` | Crouch |
| `prone` | `b` | Go prone |
| `pause` | `p` | Pause the game |
| `full_view` | Ctrl+F | Switch to the fullscreen view loop |
| `normal_view` | Ctrl+D | Switch to the normal gameplay view |
| `map_view` | Ctrl+A | Switch to the automap view |
| `save_game` | Ctrl+S | Save the game |
| `load_game` | Ctrl+L | Load the game |
| `select_grenade` | Ctrl+' | Select the next grenade type |
| `arm_grenade` | Alt+' | Arm the selected grenade |
| `select_drug` | Ctrl+; | Select the next drug/patch |
| `use_drug` | Alt+; | Use the selected drug/patch |
| `toggle_online_help` | Ctrl+H | Toggle the online help overlay |
| `show_help` | Alt+O | Show the help screen |
| `toggle_music` | Ctrl+M | Toggle music |
| `quit` | Ctrl+Q | Quit the game - opt-in only; nothing binds this by default |
| `cycle_weapon_next` | Tab | Ready the next weapon |
| `cycle_weapon_prev` | Shift+Tab | Ready the previous weapon |
| `cycle_detail` | Ctrl+1 | Cycle the graphics detail level |
| `reload_weapon_primary` | Ctrl+Backspace | Reload the current weapon's primary ammo |
| `reload_weapon_secondary` | Alt+Backspace | Reload the current weapon's secondary ammo |
| `hud_color` | Alt+H | Cycle the HUD color scheme |
| `clear_fullscreen` | Backspace | Close a fullscreen view (e.g. a datapad or email) |
| `mfd_left_1`-`mfd_left_5` | F1-F5 | Set the left MFD panel to Weapon/Item/Map/Target/Info |
| `mfd_right_1`-`mfd_right_5` | F6-F10 | Set the right MFD panel to Weapon/Item/Map/Target/Info |
| `fire_weapon` | Enter | Fire the readied weapon at the cursor (the engine's default fire key). Clicking in the 3D view with `mouse_click` also fires; this is a separate button for it |
**Not exposed** (other `HotKeyLookup[]` entries):
- `keypad 0`-`keypad 9`: a controller button per digit is not useful; use the
on-screen keyboard.
- `toggle_freelook` (F): the free-view actions cover mouse look.
- The 4 `cheat_*` entries: debug cheats. They are on the menu's Cheats tab
instead, along with four switches for unlimited health, energy, ammo and
items. Those are not saved to the config.
- `mac_help` (disabled) and `audiolog_cancel` (its build flag is not
defined): they are not real hotkeys in this build.
- `toggle_opengl` (Ctrl+G): the Video Options tab's Renderer buttons already
toggle the same setting.
- The 10 numeric hardware-item hotkeys (bio scan, fullscreen, 360 view,
lantern, shield, infrared, nav unit, data reader, booster, jumpjets): their
registered keys never match a real keypress in the engine.
### Raw input identifiers
Every raw physical input available to bind an action to, with its generic
OpenXR interaction-profile component name and its Meta Quest Touch control
name. Two symmetric identifiers (`trigger`, `squeeze`) are also accepted, but
only for `mouse_click`/`mouse_double_click`: they mean "either hand's own
trigger/grip, whichever hand is pointing". Every other identifier below
refers to one specific control.
| Identifier | OpenXR component (Touch) | Meta Quest control |
|---|---|---|
| `left_trigger` | `/user/hand/left/input/trigger/value` | Left Trigger |
| `right_trigger` | `/user/hand/right/input/trigger/value` | Right Trigger |
| `left_squeeze` | `/user/hand/left/input/squeeze/value` | Left Grip |
| `right_squeeze` | `/user/hand/right/input/squeeze/value` | Right Grip |
| `left_x` | `/user/hand/left/input/x/click` | X Button |
| `left_y` | `/user/hand/left/input/y/click` | Y Button |
| `right_a` | `/user/hand/right/input/a/click` | A Button |
| `right_b` | `/user/hand/right/input/b/click` | B Button |
| `left_thumbstick_click` | `/user/hand/left/input/thumbstick/click` | Left Thumbstick Click |
| `right_thumbstick_click` | `/user/hand/right/input/thumbstick/click` | Right Thumbstick Click |
| `left_thumbstick_up/down/left/right` | `/user/hand/left/input/thumbstick` (digitized) | Left Thumbstick, tilted |
| `right_thumbstick_up/down/left/right` | `/user/hand/right/input/thumbstick` (digitized) | Right Thumbstick, tilted |
| `trigger` | either hand's trigger | Either Trigger (`mouse_click`/`mouse_double_click` only) |
| `squeeze` | either hand's grip | Either Grip (`mouse_click`/`mouse_double_click` only) |
| `none` | - | Unbound - the action never fires |
**Menu button** (left controller, Touch/Vive/generic alike) is not in this
list - it always opens/closes the VR menu and can never be reassigned.
**Other supported controllers**: HTC Vive wands have no A/B/X/Y and no
analog grip - `left_x`/`left_y`/`right_a`/`right_b` simply never fire on
that profile, and `left/right_squeeze` are a plain click rather than
analog. Their trackpad stands in for the thumbstick identifiers above
(same digitization, just a trackpad instead of a stick). A bare-minimum
OpenXR controller (`khr/simple_controller`, used as a fallback when
neither Touch nor Vive bindings are suggested) only has `left_trigger`/
`right_trigger` (via its `select/click` component) and the menu button -
every other identifier above simply never fires on it, which is why the
Controller Options tab shows a note and hides irrelevant rows when this
profile is active.
### Controller profile files (`controller_profiles/`)
The Controller Options tab shows a labeled illustration of whichever
controller is actually active, driven by `res/controller_profiles/*.json`
(bundled with the app, copied to `/sdcard/questshock/controller_profiles/`
on every launch, refreshing the known bundled files without touching any
extra ones you add there yourself). Each file:
```json
{
"name": "Meta Quest Touch",
"interaction_profile": "/interaction_profiles/oculus/touch_controller",
"image": "touch.png",
"controls": [
{"id": "right_b", "label": "B", "anchor_x": 0.76, "anchor_y": 0.22}
]
}
```
- `interaction_profile` - the OpenXR interaction profile path this file
describes; matched against whichever one is actually bound at runtime.
Falls back to `generic.json` if nothing matches.
- `image` - filename (same folder) of the controller illustration.
- `controls` - one entry per labeled point on that illustration: `id` is a
raw input identifier from the table above (thumbstick directions share
one `left_thumbstick`/`right_thumbstick` entry, since they're the same
physical stick); `anchor_x`/`anchor_y` are 0-1 fractions of the image for
positioning that label's pointer line.
This is presentation data only - adding real support for a new controller
still needs matching bindings in the app itself; a profile file with no
corresponding code-level support just never gets matched.
### Fatal startup error
If the OpenXR runtime rejects any of the three supported controller
profiles' bindings at launch (an app bug, not something you did), a
non-cancelable dialog explains this and exits - this should never happen
in a normal build.
+84 -15
View File
@@ -1,20 +1,28 @@
# Assembles dist/ - a self-contained, runnable copy of System Shock -
# from the compiled engine (built via Docker, see build-image.sh/
# run-image.sh) and the game assets extracted from a purchased copy (see
# Assembles dist/ (Linux) and dist-win/ (Windows, cross-compiled via
# MinGW) - self-contained, runnable copies of System Shock - from the
# compiled engine (built via Docker, see build-image.sh/run-image.sh) and
# the game assets extracted from a purchased copy (see
# res/assets/extract_assets.sh). Building the engine needs the build-image
# (./build-image.sh, once); everything else here is plain file copying.
# `dist-linux`/`dist-win` build just one platform; plain `dist` builds
# both.
#
# `make package` instead builds a redistributable tarball that omits the
# proprietary game assets entirely (see res/assets/GET_ASSETS.txt, which
# it ships in their place) - this is what CI publishes.
# `make package` instead builds redistributable archives (a .tar.gz for
# Linux, a .zip for Windows) that omit the proprietary game assets
# entirely (see res/assets/GET_ASSETS.txt, which they ship in their
# place) - this is what CI publishes. Same dist-linux/dist-win/(both)
# split via package-linux/package-win/package.
DIST_DIR := dist
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 android-studio clean
.PHONY: all dist dist-linux dist-win build-image engine engine-win assets \
package package-linux package-win apk android-studio clean
all: dist
@@ -40,6 +48,16 @@ engine:
./run-image.sh; \
fi
# Cross-compiles engine/ for Windows via MinGW - see build-engine-win.sh
# for why this can't share build-engine.sh's engine/build_ext/ (a scratch
# copy is used instead). Same QUESTSHOCK_BUILD_IMAGE detection as `engine`.
engine-win:
@if [ -n "$$QUESTSHOCK_BUILD_IMAGE" ]; then \
bash build-image/build-engine-win.sh; \
else \
./run-image.sh bash build-image/build-engine-win.sh; \
fi
# Fails with a pointer to extract_assets.sh if the purchased game assets
# haven't been extracted yet.
assets:
@@ -49,7 +67,10 @@ assets:
exit 1; \
fi
dist: engine assets
# Builds both platforms. `dist-linux`/`dist-win` build just one.
dist: dist-linux dist-win
dist-linux: engine assets
@echo "== Assembling $(DIST_DIR) =="
rm -rf "$(DIST_DIR)/systemshock" "$(DIST_DIR)/lib" "$(DIST_DIR)/res" \
"$(DIST_DIR)/shaders" "$(DIST_DIR)/run.sh"
@@ -64,6 +85,27 @@ dist: engine assets
chmod +x "$(DIST_DIR)/run.sh"
@echo "== Done - run $(DIST_DIR)/run.sh to play =="
# Windows counterpart of dist-linux. Unlike dist/, the DLLs sit flat
# alongside systemshock.exe instead of in a lib/ subdirectory - Windows'
# default DLL search order already checks the executable's own directory
# first, so (unlike run.sh's LD_LIBRARY_PATH) run.bat needs no extra
# wiring for that.
dist-win: engine-win assets
@echo "== Assembling $(DIST_WIN_DIR) =="
rm -rf "$(DIST_WIN_DIR)"
mkdir -p "$(DIST_WIN_DIR)/res/data" "$(DIST_WIN_DIR)/res/sound"
cp "$(ENGINE_OUT_WIN)/systemshock.exe" "$(ENGINE_OUT_WIN)"/*.dll "$(DIST_WIN_DIR)/"
cp "$(ENGINE_OUT_WIN)/soundfont.sf2" "$(DIST_WIN_DIR)/res/"
cp -a engine/shaders "$(DIST_WIN_DIR)/shaders"
cp -a "$(ASSETS_DIR)/data/." "$(DIST_WIN_DIR)/res/data/"
cp -a "$(ASSETS_DIR)/sound/." "$(DIST_WIN_DIR)/res/sound/"
cp res/run.bat "$(DIST_WIN_DIR)/run.bat"
@echo "== Done - run $(DIST_WIN_DIR)/run.bat (or systemshock.exe) to play =="
# Builds both platforms' redistributable archives. `package-linux`/
# `package-win` build just one.
package: package-linux package-win
# Builds a versioned, redistributable Linux release tarball at
# dist/shockolate-<version>-linux-<arch>.tar.gz - everything needed to
# run except the proprietary game assets (res/GET_ASSETS.txt explains how
@@ -72,7 +114,7 @@ dist: engine assets
# tag to drive a release. Override with `make package VERSION=1.2.3`, or
# just run it untagged for a local dev build (gets a 0.0.0-dev+<sha>
# placeholder version, with a warning).
package: engine
package-linux: engine
@git config --global --add safe.directory "$$(pwd)" 2>/dev/null || true
@V="$$(VERSION="$(VERSION)" ./build-image/version.sh)"; \
PKG_NAME="shockolate-$$V-linux-$(ARCH)"; \
@@ -95,6 +137,33 @@ package: engine
rm -rf "$(BUILD_DIR)/package"; \
echo "Wrote $(DIST_DIR)/$$PKG_NAME.tar.gz"
# Windows counterpart of package-linux: dist/shockolate-<version>-windows-
# x86_64.zip. Same versioning (build-image/version.sh) and VERSION=
# override. Needs `zip` on whatever host runs `make package-win` itself
# (unlike engine-win's own build, packaging isn't run inside the
# build-image) - see README's Desktop build prerequisites.
package-win: engine-win
@git config --global --add safe.directory "$$(pwd)" 2>/dev/null || true
@command -v zip >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: zip not found in PATH" >&2; exit 1; }
@V="$$(VERSION="$(VERSION)" ./build-image/version.sh)"; \
PKG_NAME="shockolate-$$V-windows-x86_64"; \
PKG_STAGE="$(BUILD_DIR)/package/$$PKG_NAME"; \
echo "Packaging $$PKG_NAME"; \
rm -rf "$$PKG_STAGE"; \
mkdir -p "$$PKG_STAGE/res"; \
cp "$(ENGINE_OUT_WIN)/systemshock.exe" "$(ENGINE_OUT_WIN)"/*.dll "$$PKG_STAGE/"; \
cp -a engine/shaders "$$PKG_STAGE/shaders"; \
cp "$(ENGINE_OUT_WIN)/soundfont.sf2" "$$PKG_STAGE/res/"; \
cp res/assets/GET_ASSETS.txt "$$PKG_STAGE/res/GET_ASSETS.txt"; \
cp res/run.bat "$$PKG_STAGE/run.bat"; \
cp LICENSE "$$PKG_STAGE/LICENSE"; \
cp engine/LICENSE "$$PKG_STAGE/LICENSE.Shockolate"; \
cp NOTICE.txt "$$PKG_STAGE/NOTICE.txt"; \
mkdir -p "$(DIST_DIR)"; \
(cd "$(BUILD_DIR)/package" && zip -rq "$(CURDIR)/$(DIST_DIR)/$$PKG_NAME.zip" "$$PKG_NAME"); \
rm -rf "$(BUILD_DIR)/package"; \
echo "Wrote $(DIST_DIR)/$$PKG_NAME.zip"
# Builds the Quest APK (see build-image/build-apk.sh and
# android/engine-patches/ - engine/ itself is never modified; a patch is
# applied to a scratch copy at build time instead). Same
@@ -124,7 +193,7 @@ android-studio:
./run-image.sh bash build-image/prepare-android-project.sh --host-paths
clean:
rm -rf "$(DIST_DIR)" "$(BUILD_DIR)" "$(ENGINE_OUT)" \
rm -rf "$(DIST_DIR)" "$(DIST_WIN_DIR)" "$(BUILD_DIR)" "$(ENGINE_OUT)" "$(ENGINE_OUT_WIN)" \
engine/build_ext engine/CMakeCache.txt engine/CMakeFiles \
engine/cmake_install.cmake engine/Makefile engine/systemshock \
engine/src/Libraries/CMakeFiles \
+16
View File
@@ -16,3 +16,19 @@ The Android build additionally bundles:
- The Khronos Group's OpenXR-SDK loader
(https://github.com/KhronosGroup/OpenXR-SDK), prebuilt unmodified as
lib/arm64-v8a/libopenxr_loader.so. It is Apache 2.0-licensed.
The Windows build (cross-compiled via MinGW - see README's "4.2. Windows
cross-build") additionally bundles:
- GLEW (http://glew.sourceforge.net/), unmodified, as glew32.dll - used
for OpenGL extension loading, which Windows' own opengl32.dll doesn't
provide past OpenGL 1.1 (Linux instead gets this straight from Mesa's
headers, needing no separate loader library - see
engine/src/MacSrc/OpenGL.cc). It is licensed under a combination of the
Modified BSD License, the MIT License, and the Khronos License, all
permissive.
- The MinGW-w64 runtime's winpthreads library, as libwinpthread-1.dll. It
is MIT-licensed. (GCC's own runtime, libgcc/libstdc++, is linked
statically into systemshock.exe instead of shipped as a DLL, under the
GCC Runtime Library Exception - this doesn't subject the rest of the
binary to the GPL.)
+559 -31
View File
@@ -6,6 +6,17 @@ An open source project to play the classic 1994 System Shock on a VR
headset, built on top of [Shockolate](https://github.com/Interrupt/systemshock),
a cross-platform port of the original game.
The game is played on a virtual screen: the engine renders its usual flat 2D
frame, and Questshock shows it as a large screen floating in front of you in
VR. It is not a stereoscopic 3D conversion; you sit or stand in front of the
screen the way you would in front of a monitor or a cinema screen. The
screen's width and distance can be set separately for the menus/intro and for
gameplay. Both controllers act as laser pointers for the game's mouse-driven
interface and for a VR menu and on-screen keyboard, and every controller
button, stick direction and game hotkey can be assigned to a game action.
Questshock runs entirely on the headset (tested on a Meta Quest 2) as a
sideloaded Android app, and needs your own copy of the game data.
## 1. Design principles
- **Standalone-headset-only.** The game must run entirely on the headset
@@ -31,6 +42,10 @@ a cross-platform port of the original game.
"3. Game assets" below); `res/assets/extract_assets.sh` extracts it into
`ss_ee/`.
- `res/run.sh` - the launcher script, copied into `dist/` on build.
- `res/questshock.config.default.json` - the bundled default configuration
(see [CONFIG.md](CONFIG.md)); `res/controller_profiles/` - the per-controller
illustrations and labels shown in the Controller Options tab (see
[CONFIG.md](CONFIG.md)). Both are copied into the APK.
- `Makefile` - targets:
- `all` (default) - alias for `dist`.
- `build-image` - builds the Docker build-image (`./build-image.sh`).
@@ -90,12 +105,51 @@ package`/`make apk` pick it up the same way either way. This one copy of
game assets feeds both the desktop build and the Android/Quest build
below.
**Note on French/German**: the GOG Enhanced Edition installer only ships
English MFD (Multi-Function Display - the small in-game screens showing
the map, inventory, etc.) art resources (`mfdart.res`) - it doesn't include
`mfdfrn.res`/`mfdger.res`, which French/German need in addition to their
own text resources (`frnstrng.res`/`gerstrng.res`, which the installer
*does* include). Without those two files, switching to French/German (via
`questshock.config.json`'s `general.language` or the in-headset menu's
Language row - see [CONFIG.md](CONFIG.md)) is refused with an in-headset
popup rather than applied, since doing so anyway used to crash the game. If
you also own **System Shock: Classic** on gog.com (which does include both
files), drop its offline installer into `res/assets/` alongside the
Enhanced Edition one and `extract_assets.sh` pulls them out of it too,
entirely optionally - it still runs fine with just the Enhanced Edition
installer.
The intro cutscene follows the selected language too, when the matching
voice-over file (`svfrintr.res`/`svgeintr.res` under `res/data/`) is present;
otherwise the English intro plays.
## 4. Desktop build
Builds and runs Questshock natively on Linux (your dev machine, or any
Linux box) - useful for local development and testing without a VR
headset at all. For the VR-headset build, see "5. Android / Quest build"
below instead.
Builds and runs Questshock natively on the desktop (your dev machine, or
any Linux/Windows box) - useful for local development and testing
without a VR headset at all. For the VR-headset build, see "5. Android /
Quest build" below instead.
Two platforms: Linux (native) and Windows (x86_64, cross-compiled via
MinGW - see "4.2. Windows cross-build" below for how). `make dist`/`make
package` build **both** by default; use `make dist-linux`/`make
dist-win` (or `package-linux`/`package-win`) to build just one.
**Prerequisites:**
- **Docker** - `build-image.sh`/`run-image.sh` build and run the engine
build-image; no other build tool touches the host directly. The
Windows cross-toolchain (MinGW) is baked into the same build-image, so
building `dist-win` needs nothing extra beyond Docker itself.
- **`innoextract` and `unzip`** - optional, only needed by
`res/assets/extract_assets.sh` (see "3. Game assets" above) to unpack
the GOG installer locally; if either is missing, that script falls
back to running the extraction inside a throwaway Docker container
instead.
- **`zip`** - only needed on the host for `make package`/`package-win`
(the Windows release archive); `make dist`/`dist-win` and
`package-linux` don't need it.
```sh
# 1. Build the engine build-image (once, or after build-image/ changes)
@@ -104,11 +158,12 @@ below instead.
# 2. Get your own copy of the game data (see "3. Game assets" above), then:
res/assets/extract_assets.sh
# 3. Compile the engine and assemble dist/
# 3. Compile the engine and assemble dist/ (Linux) and dist-win/ (Windows)
make dist
# 4. Play
dist/run.sh
dist/run.sh # Linux
dist-win/run.bat # Windows (or systemshock.exe directly)
```
`make dist` always recompiles the engine from the current `engine/`
@@ -117,14 +172,38 @@ source (via `run-image.sh`), so a fresh build-image plus a re-run of
### 4.1. Packaging a distributable build
`make package` builds `dist/shockolate-<version>-linux-<arch>.tar.gz`: the
compiled binary, its runtime libraries, shaders, a default MIDI
soundfont, license information, and `res/GET_ASSETS.txt` in place of the
actual game data (which the tarball never includes). Version comes from
the current git tag (push a `vX.Y.Z` tag to drive a release); without one
it builds an untagged `0.0.0-dev+<sha>` placeholder. `make apk` (see
"5. Android / Quest build" below) is versioned identically, via the same
`build-image/version.sh`.
`make package` builds both `dist/shockolate-<version>-linux-<arch>.tar.gz`
and `dist/shockolate-<version>-windows-x86_64.zip`: the compiled
binary/DLLs, shaders, a default MIDI soundfont, license information, and
`res/GET_ASSETS.txt` in place of the actual game data (which neither
archive ever includes). Version comes from the current git tag (push a
`vX.Y.Z` tag to drive a release); without one it builds an untagged
`0.0.0-dev+<sha>` placeholder. `make apk` (see "5. Android / Quest build"
below) is versioned identically, via the same `build-image/version.sh`.
### 4.2. Windows cross-build
`make dist-win`/`make package-win` cross-compile a native Windows x86_64
build via MinGW (`x86_64-w64-mingw32-gcc`/`g++`, baked into the
build-image alongside the Linux toolchain - see
`build-image/build-engine-win.sh` and the "Windows cross-compile" section
of `build-image/Dockerfile`) - no Windows machine, Wine, or VM involved
in building it. `engine/CMakeLists.txt` already has working `WIN32`/
`MINGW` branches from Shockolate's own upstream Windows build (built
natively via Git Bash/MinGW on a real Windows machine - see
`engine/build_win64.sh`/`engine/appveyor.yml`), so unlike the Quest
build's `android/engine-patches/`, no source patching is needed here -
this cross-compiles those same branches offline and reproducibly, from
Linux, via Docker.
`dist-win/`/the packaged `.zip` ship `systemshock.exe` with its DLLs
(SDL2, SDL2_mixer, GLEW, fluidsynth-lite, plus the MinGW pthread runtime)
sitting flat alongside it, rather than in a `lib/` subdirectory like
`dist/` - Windows' default DLL search order already checks the
executable's own directory first, so no `PATH`/library-path setup is
needed the way `dist/run.sh` needs `LD_LIBRARY_PATH`.
Confirmed working (and playable) on a real Windows machine.
A Gitea Actions workflow (`.gitea/workflows/build.yml`) builds this
package on every push, using the build-image as its container (so no
@@ -133,6 +212,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
@@ -146,18 +237,17 @@ driven by a laser-pointer-style aim ray from each hand
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 menu (`MenuOverlay.java`, described in "5.1.1. Menu, controls and
cheats" below) and the keyboard (`KeyboardOverlay.java`) are two fully
independent quads, each its own `XrOverlay` instance
(`android/app/src/main/cpp/xr_overlay.c` - a generic "off-screen Android View
rendered into an OpenXR quad, hit-tested via a laser pointer" module shared by
both) with its own swapchain, visibility, and position, so they can be
shown/hidden/moved independently - the keyboard can stay open while also
picking something from the menu. The controller's menu button only ever
opens/closes the menu; the menu's "Keyboard" button only opens the keyboard
(never touching the menu's own visibility) - the keyboard's own title bar
holds the only way to close it again.
The keyboard is a hand-built, full US-layout key grid (there's no system
IME to borrow once immersive) that forwards each key press straight to the
@@ -213,6 +303,54 @@ 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. Every launch also refreshes `questshock.config.default.json` (the
bundled defaults, used when there is no `questshock.config.json`) and
`controller_profiles/` (controller illustrations for the Controller Options
tab) there; your own saved config and any extra profiles you add are left
alone.
#### 5.1.1. Menu, controls and cheats
The controller's menu button (left controller) opens and closes the menu. It
can't be reassigned. Opening it pauses a running game; closing it resumes the
game, unless it was already paused. Aim with either hand's ray and pull the
trigger to press buttons. The menu has four tabs:
- **Main Menu** - Keyboard, Save/Load Configuration, Reset Position and the
language.
- **Video Options** - renderer, resolution, and the width and distance of the
virtual screen for the initial screen (splash, intro, main menu) and the game
screen.
- **Controller Options** - shows the active controller with labelled inputs,
and one button per action to assign it to a controller input (tap the
button, then press the input). Assigning an input that another action
already uses unbinds that other action. **Reset to Defaults** restores the
default bindings.
- **Cheats** - the engine's cheats as buttons (give all items and weapons,
toggle physics, next and previous level) and four independent switches for
unlimited health, energy, ammo and items. The switches last until the app
restarts and are not saved. They only work while a game is running.
Default controls (all assignable, see [CONFIG.md](CONFIG.md)):
| Input | On foot | In cyberspace |
|---|---|---|
| Left stick | Move forward/back, strafe | Pitch up/down, bank left/right |
| Right stick left/right | Turn | Roll |
| Right stick up | Stand | Thrust |
| Right stick down | Prone | - |
| Left stick click | Jump | - |
| Right stick click | Automap | - |
| Right A / B | Click / double-click | Click / double-click |
| Right trigger | Fire weapon | Fire weapon |
| Left grip (hold) | Free view with the left stick | Free view with the left stick |
Every game hotkey (weapon select, MFD panels, save/load, ...) can also be
assigned to a controller input; they are unbound by default.
### 5.2. Building natively in Android Studio
`make apk` always compiles the engine and links the APK inside the
@@ -290,6 +428,13 @@ the same as any other Android Studio project:
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
@@ -319,10 +464,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.
@@ -334,13 +483,134 @@ 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.
- **`22-android-screen-profile.patch`** - `mainloop.c` reports every
loop-mode change to `qs_screen_profile_notify_loop_mode()`, which picks
between the initial-screen and game-screen virtual-screen presets (see
[CONFIG.md](CONFIG.md)) and tells the menu whether a game is running.
- **`23-android-intro-language.patch`** - `cutsloop.c` opens the intro
cutscene file for the current language (`svgaintr.res`/`svfrintr.res`/
`svgeintr.res`), falling back to English when it isn't installed.
- **`24-android-cyberspace-query.patch`** - adds `qs_in_cyberspace()` to
`mouselook.c`, which `xr_input.c` uses to switch between the on-foot and
cyberspace controller actions.
- **`25-android-cyberspace-analog-scale.patch`** - `movekeys.c` scales the
cyberspace pitch and bank strength by `qs_cyber_pitch_scale`/
`qs_cyber_bank_scale`, which `xr_input.c` sets from the thumbstick
deflection each frame (1.0 for keys and buttons).
- **`26-android-cheat-refill.patch`** - adds `qs_cheat_refill()` to
`hkeyfunc.c`, which tops up health, energy, ammo and items (grenades and
drugs the player already holds) for the Cheats tab's unlimited switches.
`android/gl4es-patches/`:
@@ -402,6 +672,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
+39 -4
View File
@@ -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
@@ -80,10 +104,14 @@ def stageEngineTask = tasks.register("stageEngine", Exec) {
inputs.dir("${projectDir}/../gl4es-src")
inputs.dir("${projectDir}/../gl4es-patches")
inputs.file("${repoRoot}/build-image/Dockerfile")
// Staged into assets/ by prepare-android-project.sh.
inputs.file("${repoRoot}/res/questshock.config.default.json")
inputs.dir("${repoRoot}/res/controller_profiles")
outputs.dir("${repoRoot}/build/android-engine")
outputs.dir("${repoRoot}/build/android-gl4es")
outputs.dir("${repoRoot}/build/android-prebuilt")
outputs.file("${projectDir}/../engine.properties")
outputs.dir("${projectDir}/src/main/assets")
}
// preBuild is what every variant's compile/native-build tasks already
@@ -171,7 +199,7 @@ android {
"-DCMAKE_FIND_ROOT_PATH_MODE_LIBRARY=BOTH", \
"-DCMAKE_FIND_ROOT_PATH_MODE_INCLUDE=BOTH", \
"-DCMAKE_SHARED_LINKER_FLAGS=-Wl,-z,max-page-size=16384", \
"-DANDROID_EXTRA_SOURCES=${projectDir}/src/main/cpp/questshock_native.c;${projectDir}/src/main/cpp/xr_session.c;${projectDir}/src/main/cpp/xr_input.c;${projectDir}/src/main/cpp/xr_overlay.c;${projectDir}/src/main/cpp/xr_swapchain.c"
"-DANDROID_EXTRA_SOURCES=${projectDir}/src/main/cpp/questshock_native.c;${projectDir}/src/main/cpp/xr_session.c;${projectDir}/src/main/cpp/xr_input.c;${projectDir}/src/main/cpp/xr_overlay.c;${projectDir}/src/main/cpp/xr_swapchain.c;${projectDir}/src/main/cpp/xr_mouse.c;${projectDir}/src/main/cpp/qs_config.c"
abiFilters 'arm64-v8a'
}
}
@@ -189,6 +217,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
}
File diff suppressed because it is too large Load Diff
+195
View File
@@ -0,0 +1,195 @@
#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 as the three above - called from the Main Menu
// tab's "Reset Position" button. Re-anchors the player's position in front of
// the game quad (see xr_input_reset_player_position()); never reads or writes
// the config file.
void qs_config_request_reset_position(void);
// Same cross-thread-dispatch reasoning as the four above - called from the
// Video Options tab's "Initial Screen"/"Game Screen" width/distance
// preset rows (MenuOverlay.java). preset_index indexes qs_config.c's own
// shared, offset+step-generated width/distance preset lists (both in
// meters; the offset/step themselves are config-file-only, not exposed
// as buttons), read back via the four qs_config_current_*_screen_*_index()
// getters below. Applies live immediately if the just-changed profile is
// the currently active one (see CONFIG.md) - otherwise just updates the
// stored preference, invisibly, until that profile becomes active (e.g.
// tweaking "Initial Screen" mid-game).
void qs_config_request_set_initial_screen_width(int preset_index);
void qs_config_request_set_initial_screen_distance(int preset_index);
void qs_config_request_set_game_screen_width(int preset_index);
void qs_config_request_set_game_screen_distance(int preset_index);
// Cross-thread requests from the Controller Options tab (MenuOverlay.java),
// enqueued like every request_*() above. action_index indexes
// xr_input_action_name()'s list. Begin/cancel start and stop rebind capture;
// reset restores all bindings (the built-in defaults, then
// questshock.config.default.json's player_controls group if it has one).
void qs_config_request_begin_control_capture(int action_index);
void qs_config_request_cancel_control_capture(void);
void qs_config_request_reset_player_controls(void);
// Called from xr_input.c's xr_input_process_capture() on the engine's own
// thread, so no cross-thread dispatch is needed (as with
// qs_screen_profile_notify_loop_mode() below). Resolves
// MenuOverlay.onControlCaptureResult(int, String) for action_index (raw_name
// NULL on timeout or cancel), and again for displaced_action_index if the new
// binding took the input from another action (-1/NULL when none), so both
// rows can be updated together.
void qs_config_notify_control_capture_result(int action_index, const char *raw_name,
int displaced_action_index,
const char *displaced_raw_name);
// Called from xr_input.c whenever the VR menu's visibility changes. Showing
// the menu pauses a running game (not the main menu, intro or cutscenes) that
// is not already paused; hiding it unpauses only if the menu did the pausing.
// Enqueued like the request_*() functions above; the pause itself runs on the
// engine's thread at pump_events() time.
void qs_config_notify_menu_visibility(bool visible);
// Cheats tab (MenuOverlay.java). Same enqueue-only requests as above; the work
// runs on the engine's thread. cheat_id: 0 give everything, 1 toggle physics,
// 2 next level, 3 previous level, 4-7 toggle unlimited health, energy, ammo,
// items (grenades and drugs). Cheats need a running game (the unlimited
// switches can be set anywhere). The getters are plain scalar reads, safe from
// the UI thread; the unlimited mask has bit 0 health, 1 energy, 2 ammo, 3 items.
void qs_config_request_cheat(int cheat_id);
int qs_config_cheat_unlimited_mask(void);
bool qs_config_cheat_physics_running(void);
// Called once per frame from xr_input.c on the engine's thread; applies the
// unlimited-resource cheats that are switched on.
void qs_config_frame_tick(void);
// Called from xr_input.c's xr_input_suggest_bindings() when the OpenXR runtime
// rejects a supported interaction profile's suggested bindings at startup.
// That is an app bug, not a config problem, so it shows
// MenuOverlay.showFatalControllerBindingError()'s non-cancelable "Exit" dialog;
// xr_input_init() aborts right after.
void qs_config_report_fatal_input_error(const char *profile_display_name);
// Returns true and handles the event if it is one of the custom events the
// request_*()/notify functions above enqueue. Called from pump_events()
// (sdl_events.c, see android/engine-patches/21-android-config.patch) on the
// engine's thread, once per polled event. Returns false for any other event
// type, including before the custom types are registered.
bool qs_config_handle_event(const SDL_Event *event);
// Called directly (no event-queue dispatch - see
// android/engine-patches/22-android-screen-profile.patch and this
// function's own definition in qs_config.c for why) from the engine's
// mainloop.c whenever the loop mode changes, and once from
// qs_config_load_startup() - picks between the "Initial Screen" (menu/
// intro) and "Game Screen" (actual gameplay) presets based on new_mode
// (mainloop.h's SETUP_LOOP/CUTSCENE_LOOP vs. everything else) and applies
// the newly-active one's shape to xr_session.c, but only if it's actually
// different from what was already active.
void qs_screen_profile_notify_loop_mode(short new_mode);
// 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);
// -1 (not just an unlikely fallback, a routine, expected result here -
// unlike qs_config_current_resolution_index() above) if the profile's
// current value doesn't exactly match one of qs_config.c's own
// offset+step-generated width/distance presets, e.g. after a hand-edited
// config file sets an in-between value - MenuOverlay.java's own
// setSelected() naturally shows no button selected in that case, no
// special-casing needed.
int qs_config_current_initial_screen_width_index(void);
int qs_config_current_initial_screen_distance_index(void);
int qs_config_current_game_screen_width_index(void);
int qs_config_current_game_screen_distance_index(void);
// Returns the value preset button `index` (0..4) currently shows for the
// width/distance option - shared by both "Initial Screen" and "Game
// Screen" columns (their offset/step is config-file-only, not per-profile
// - see qs_config.c's own comment on the underlying statics), so
// MenuOverlay.java can label its preset buttons correctly even after a
// Load Configuration changes the offset/step. Index is clamped into
// range, not validated - every index 0..4 always has a value.
float qs_config_screen_width_preset_value(int index);
float qs_config_screen_distance_preset_value(int index);
// 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
+239 -10
View File
@@ -4,11 +4,14 @@
// 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_input.h"
#include "xr_overlay.h"
#include "xr_session.h"
@@ -20,17 +23,30 @@ Java_de_ladkau_questshock_QuestShockActivity_nativeChdir(JNIEnv *env, jclass cla
}
// KeyboardOverlay's hand-built on-screen keyboard (see
// KeyboardOverlay.commitPrintableChar()) 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 (see handleKeyPress() in
// KeyboardOverlay.java for why).
// 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_nativeSendPrintableChar(JNIEnv *env, jclass clazz,
jchar c) {
Java_de_ladkau_questshock_KeyboardOverlay_nativeSendPrintableCharDown(JNIEnv *env, jclass clazz,
jchar c) {
SDL_Event event;
SDL_zero(event);
event.type = SDL_TEXTINPUT;
@@ -41,6 +57,23 @@ Java_de_ladkau_questshock_KeyboardOverlay_nativeSendPrintableChar(JNIEnv *env, j
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
@@ -57,3 +90,199 @@ 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();
}
// MenuOverlay's Video Options tab - "Initial Screen"/"Game Screen" width/
// distance preset rows, same enqueue-only reasoning as above.
JNIEXPORT void JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeRequestSetInitialScreenWidth(JNIEnv *env, jclass clazz,
jint presetIndex) {
qs_config_request_set_initial_screen_width(presetIndex);
}
JNIEXPORT void JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeRequestSetGameScreenWidth(JNIEnv *env, jclass clazz,
jint presetIndex) {
qs_config_request_set_game_screen_width(presetIndex);
}
JNIEXPORT void JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeRequestSetInitialScreenDistance(JNIEnv *env,
jclass clazz,
jint presetIndex) {
qs_config_request_set_initial_screen_distance(presetIndex);
}
JNIEXPORT void JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeRequestSetGameScreenDistance(JNIEnv *env, jclass clazz,
jint presetIndex) {
qs_config_request_set_game_screen_distance(presetIndex);
}
// 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_nativeGetInitialScreenWidthIndex(JNIEnv *env, jclass clazz) {
return qs_config_current_initial_screen_width_index();
}
JNIEXPORT jint JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeGetGameScreenWidthIndex(JNIEnv *env, jclass clazz) {
return qs_config_current_game_screen_width_index();
}
JNIEXPORT jint JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeGetInitialScreenDistanceIndex(JNIEnv *env,
jclass clazz) {
return qs_config_current_initial_screen_distance_index();
}
JNIEXPORT jint JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeGetGameScreenDistanceIndex(JNIEnv *env, jclass clazz) {
return qs_config_current_game_screen_distance_index();
}
// Preset button *labels* (not the current value) - shared by both
// "Initial Screen" and "Game Screen" columns, since their offset/step is
// config-file-only, not per-profile (see qs_config.h's own comment).
JNIEXPORT jfloat JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeGetScreenWidthPresetValue(JNIEnv *env, jclass clazz,
jint index) {
return qs_config_screen_width_preset_value(index);
}
JNIEXPORT jfloat JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeGetScreenDistancePresetValue(JNIEnv *env, jclass clazz,
jint index) {
return qs_config_screen_distance_preset_value(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();
}
// Controller Options tab: actionIndex follows xr_input.c's action order
// (MenuOverlay.java and Strings.java hold the matching labels).
JNIEXPORT void JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeRequestBeginControlCapture(JNIEnv *env, jclass clazz,
jint actionIndex) {
qs_config_request_begin_control_capture(actionIndex);
}
JNIEXPORT void JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeRequestCancelControlCapture(JNIEnv *env, jclass clazz) {
qs_config_request_cancel_control_capture();
}
JNIEXPORT void JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeRequestResetPlayerControls(JNIEnv *env, jclass clazz) {
qs_config_request_reset_player_controls();
}
JNIEXPORT jstring JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeGetControlBindingKey(JNIEnv *env, jclass clazz,
jint actionIndex) {
return (*env)->NewStringUTF(env, xr_input_get_action_binding_name(actionIndex));
}
JNIEXPORT jstring JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeGetActiveInteractionProfilePath(JNIEnv *env,
jclass clazz) {
return (*env)->NewStringUTF(env, xr_input_get_active_interaction_profile_path());
}
JNIEXPORT void JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeSetControllerOptionsTabOpen(JNIEnv *env, jclass clazz,
jboolean open) {
xr_input_set_controller_options_tab_open(open == JNI_TRUE);
}
// Cheats tab: same enqueue-only pattern as the other requests.
JNIEXPORT void JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeRequestCheat(JNIEnv *env, jclass clazz, jint cheatId) {
qs_config_request_cheat(cheatId);
}
JNIEXPORT jint JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeGetCheatUnlimitedMask(JNIEnv *env, jclass clazz) {
return qs_config_cheat_unlimited_mask();
}
JNIEXPORT jboolean JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeGetCheatPhysicsRunning(JNIEnv *env, jclass clazz) {
return qs_config_cheat_physics_running();
}
File diff suppressed because it is too large Load Diff
+106 -17
View File
@@ -1,16 +1,25 @@
// Controller input for questshock's immersive Quest build: one OpenXR
// action set (aim pose + trigger click per hand, a menu-toggle button on
// the left controller) plus ray/quad hit-testing. While neither the menu
// launcher nor keyboard overlay (see xr_overlay.h, xr_session.c) is
// visible, this tests against the game quad xr_session.c submits and draws
// a small reticle where each hand's aim ray crosses it; while either
// overlay is visible, it tests against that overlay's quad instead (the
// keyboard is tried first), forwards trigger edges as synthetic touches,
// and - since neither overlay's own on-quad cursor gives any feedback
// while the ray is short of actually landing on it - builds a thin,
// 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 overlay's plane (see
// xr_input_get_beam_layer()).
// 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
@@ -38,17 +47,18 @@ bool xr_input_init(XrInstance instance, XrSession session, int64_t swapchain_for
// 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 or menu
// overlay this frame (see the file comment above), fills *out_quad's
// 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
@@ -56,6 +66,85 @@ void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw);
// 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 are standing and facing, from their current real head
// pose. Never reads or writes questshock.config.json. Called automatically
// once, from xr_input_sync_and_draw(), on the first frame with a valid head
// pose after startup, unless a complete saved player_position was loaded (see
// xr_input_skip_startup_recenter()): a saved position is relative to a
// previous session's real-space origin, which is not stable across launches.
// Returns false (no-op) if no valid head pose is available yet.
bool xr_input_recenter_player_to_screen(void);
// The Main Menu tab's "Reset Position" button (via qs_config.c). Like
// xr_input_recenter_player_to_screen(), but re-anchors only the player's
// position to the centre in front of the game quad; the current facing (yaw)
// is kept, so the screen does not rotate to follow the gaze. Returns false
// (no-op) if no valid head pose is available yet.
bool xr_input_reset_player_position(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);
// ===========================================================================
// Remappable controls ("player_controls" config group; see qs_config.c and
// CONFIG.md). menu_toggle is not among them and has no getter/setter: it is
// fixed (see xr_input_sync_and_draw()).
// ===========================================================================
// Number of remappable actions and each one's index<->name mapping, which
// qs_config.c uses to parse/write the player_controls group without its own
// copy of the list. The names are the keys used in questshock.config.json;
// index_from_name() returns false for an unrecognized name.
int xr_input_action_count(void);
const char *xr_input_action_name(int action_index);
bool xr_input_action_index_from_name(const char *name, int *out_index);
// Current binding for one action, as an opaque int and as its string form
// (e.g. "right_b", "trigger", "none" for unbound), the same strings used in
// questshock.config.json and reported by capture.
int xr_input_get_action_binding(int action_index);
const char *xr_input_get_action_binding_name(int action_index);
// Sets one action's binding from a config-file string. Returns false (binding
// unchanged) for an unrecognized name, or for the symmetric "trigger"/
// "squeeze" on an action other than mouse_click/mouse_double_click. Does not
// enforce one-action-per-input the way capture does: a hand-edited file that
// binds two actions to one input is accepted as-is.
bool xr_input_set_action_binding(int action_index, const char *raw_name);
// Restores every action to its built-in default (the table xr_input_init()
// starts from). qs_config.c calls this first for "Reset to Defaults", then
// applies questshock.config.default.json's player_controls group on top if it
// has one.
void xr_input_reset_player_controls(void);
// Begins/cancels rebind capture for one action (an index into
// xr_input_action_name()'s list): the next fresh button or thumbstick-direction
// edge on any hand becomes that action's binding and is reported via
// qs_config_notify_control_capture_result(); with no press, capture cancels
// after 8 seconds. Called via qs_config.c's cross-thread request functions.
// The menu button is never captured.
void xr_input_capture_begin(int action_index);
void xr_input_capture_cancel(void);
// The OpenXR interaction profile currently bound (e.g.
// "/interaction_profiles/oculus/touch_controller"), refreshed once per frame.
// MenuOverlay.java's Controller Options tab uses it to pick a file from
// controller_profiles/ (see CONFIG.md). Empty until the first successful
// query.
const char *xr_input_get_active_interaction_profile_path(void);
// Set by MenuOverlay.java when the Controller Options tab becomes or stops
// being the menu's current tab. While it is open, the menu's thumbstick
// locomotion (snap turn/smooth move) is disabled.
void xr_input_set_controller_options_tab_open(bool open);
void xr_input_shutdown(void);
#ifdef __cplusplus
+20
View File
@@ -0,0 +1,20 @@
#include "xr_mouse.h"
#include <SDL.h>
#include "mouse.h" // engine/src/Libraries/INPUT/Source/mouse.h - mouse_put_xy()
void xr_mouse_move(int x, int y) { mouse_put_xy((short)x, (short)y); }
void xr_mouse_click(bool down) {
SDL_Event event;
SDL_zero(event);
event.type = down ? SDL_MOUSEBUTTONDOWN : SDL_MOUSEBUTTONUP;
event.button.timestamp = SDL_GetTicks();
event.button.windowID = 0;
event.button.which = 0;
event.button.button = SDL_BUTTON_LEFT;
event.button.state = down ? SDL_PRESSED : SDL_RELEASED;
event.button.clicks = 1;
SDL_PushEvent(&event);
}
+36
View File
@@ -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
+62 -3
View File
@@ -1,6 +1,7 @@
#include "xr_overlay.h"
#include <dlfcn.h>
#include <math.h>
#include <stdlib.h>
#include <string.h>
@@ -18,11 +19,33 @@
#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;
@@ -205,6 +228,7 @@ XrOverlay *xr_overlay_create(XrInstance instance, XrSession session, int64_t swa
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;
@@ -281,9 +305,10 @@ void xr_overlay_set_visible(XrOverlay *overlay, bool 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 *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 = 0.0f;
*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;
@@ -291,6 +316,7 @@ void xr_overlay_get_quad_extent(const XrOverlay *overlay, float *center_x_m, flo
*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) {
@@ -300,6 +326,20 @@ void xr_overlay_set_position(XrOverlay *overlay, float center_x_m, float center_
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;
@@ -427,6 +467,15 @@ bool xr_overlay_render_and_build_layer(XrOverlay *overlay, XrCompositionLayerQua
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));
@@ -434,7 +483,17 @@ bool xr_overlay_render_and_build_layer(XrOverlay *overlay, XrCompositionLayerQua
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;
out_quad->pose.orientation.w = 1.0f;
// 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;
+27 -6
View File
@@ -58,18 +58,39 @@ void xr_overlay_toggle_visible(XrOverlay *overlay);
void xr_overlay_set_visible(XrOverlay *overlay, bool visible);
bool xr_overlay_is_visible(const XrOverlay *overlay);
// Local-space pose/size of the quad, valid regardless of visibility -
// shared with xr_input.c's ray/quad hit-testing.
// 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 *distance_m, float *half_width_m, float *half_height_m,
float *yaw_deg);
// Repositions the quad (world-space X/Y offset, same units/space as
// 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 isn't adjustable this way - only left/right/up/down
// repositioning, not push/pull.
// 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);
+468 -48
View File
@@ -1,5 +1,7 @@
#include "xr_session.h"
#include <ctype.h>
#include <math.h>
#include <stdlib.h>
#include <string.h>
@@ -24,14 +26,18 @@
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__)
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
// Fixed pose/size for the single game quad - 2m in front of the local space
// origin, sized to preserve the game's own aspect ratio at a comfortable
// visual angle. No per-eye view/projection work is needed for a quad layer
// at all - the compositor handles reprojecting it per eye itself, which is
// exactly why this milestone doesn't need xrLocateViews or any stereo
// rendering (see the plan's step A notes).
// Original, pre-screen-profile-feature default distance for the game
// screen - 2m in front of the local space origin - now just the initial
// value of g_screen_distance_m (below) rather than a fixed constant.
// Width/curve/distance are all configurable (see g_screen_width_m/
// g_screen_curve_deg/g_screen_distance_m above and CONFIG.md's Video
// Options "Initial Screen"/"Game Screen" settings), sized to preserve the
// game's own pixel aspect ratio at whatever physical width is currently
// configured. No per-eye view/projection work is needed for a quad/
// cylinder layer at all - the compositor handles reprojecting it per eye
// itself, which is exactly why this milestone doesn't need xrLocateViews
// or any stereo rendering (see the plan's step A notes).
#define QUAD_DISTANCE_METERS 2.0f
#define QUAD_WIDTH_METERS 1.6f
// The menu launcher (just a "Keyboard" button - see MenuOverlay.java) is
// small and sits above where the keyboard defaults to; the keyboard keeps
@@ -39,10 +45,22 @@
// 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"
#define MENU_WIDTH 512
#define MENU_HEIGHT 256
// 768x800 (was 512x256, then 768x512, then 768x552) - 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, then again to fit the Video Options tab's screen-size grid; 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()). Must match MenuOverlay.java's own
// WIDTH/HEIGHT, and xr_input.c's MENU_TITLE_BAR_V_FRACTION denominator.
#define MENU_WIDTH 768
#define MENU_HEIGHT 800
#define MENU_DISTANCE_METERS 1.3f
#define MENU_WIDTH_METERS 0.35f
// 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
@@ -58,6 +76,26 @@ 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*
@@ -79,6 +117,40 @@ static XrTime g_predicted_display_time = 0;
static bool g_frame_should_render = false;
static bool g_have_acquired_game_image = false;
// 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;
// XR_KHR_composition_layer_cylinder support - same "only if the runtime
// advertised it" reasoning as g_performance_metrics_supported above. If
// unsupported, the curve is forced to 0 (flat) everywhere regardless of
// configuration (see xr_get_game_quad_extent()) so the game screen just
// silently stays flat instead of failing to submit a layer at all.
static bool g_cylinder_layer_supported = false;
// The game screen's currently-configured shape (see qs_config.c's
// qs_screen_profile_notify_loop_mode(), which is what actually calls
// xr_session_set_game_screen_shape() below - this file has no notion of
// "which profile" is active, only "what shape is current"). Defaults
// match the game quad's own original, pre-this-feature hardcoded width
// (1.6m) and lack of curve, so a build that never touches the new config
// options renders identically to before.
static float g_screen_width_m = 1.6f;
static float g_screen_curve_deg = 0.0f;
static float g_screen_distance_m = QUAD_DISTANCE_METERS;
static bool xr_check(XrResult result, const char *what) {
if (XR_SUCCEEDED(result))
return true;
@@ -99,6 +171,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
@@ -133,6 +305,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();
@@ -146,10 +377,26 @@ 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);
g_cylinder_layer_supported =
xr_instance_extension_supported(XR_KHR_COMPOSITION_LAYER_CYLINDER_EXTENSION_NAME);
// The two KHR extensions here are always required; the other two are
// each independently optional device capabilities (see
// g_performance_metrics_supported/g_cylinder_layer_supported's own
// comments) - appended only if actually advertised, since listing an
// unsupported extension fails xrCreateInstance entirely, not just that
// one feature.
const char *extensions[4] = {
XR_KHR_ANDROID_CREATE_INSTANCE_EXTENSION_NAME,
XR_KHR_OPENGL_ES_ENABLE_EXTENSION_NAME,
};
uint32_t extensionCount = 2;
if (g_performance_metrics_supported)
extensions[extensionCount++] = XR_META_PERFORMANCE_METRICS_EXTENSION_NAME;
if (g_cylinder_layer_supported)
extensions[extensionCount++] = XR_KHR_COMPOSITION_LAYER_CYLINDER_EXTENSION_NAME;
XrInstanceCreateInfoAndroidKHR androidInfo = {XR_TYPE_INSTANCE_CREATE_INFO_ANDROID_KHR};
androidInfo.applicationVM = vm;
@@ -157,7 +404,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 = extensionCount;
createInfo.enabledExtensionNames = extensions;
strncpy(createInfo.applicationInfo.applicationName, "QuestShock",
XR_MAX_APPLICATION_NAME_SIZE - 1);
@@ -206,6 +453,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;
@@ -213,6 +462,14 @@ static bool xr_create_instance_and_session(int game_width, int game_height) {
"xrCreateReferenceSpace"))
return false;
// 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
// data conventionally is) and get uploaded/sampled as plain linear
@@ -319,6 +576,14 @@ 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; }
@@ -346,20 +611,86 @@ bool xr_frame_begin(void) {
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 game swapchain
// framebuffer before releasing it, so it composites on top of whatever
// this frame's game content already drew there. Syncing actions
// happens even when there's nothing to draw (no acquired image this
// frame), so edge detection (trigger/menu-button clicks) doesn't miss a
// frame. Only ever draws while neither overlay is visible (see
// xr_input.c) - the menu/keyboard render into their own independent
// swapchain images below.
// 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,
xr_input_sync_and_draw(g_play_space, g_predicted_display_time,
g_have_acquired_game_image);
if (g_have_acquired_game_image)
@@ -368,34 +699,58 @@ void xr_frame_end(void) {
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 one of those two
// overlays (see xr_input_get_beam_layer()).
// Up to 5 layers: the game screen (if a frame was actually rendered) -
// a flat quad, or a curved cylinder if the currently-active screen
// profile has a nonzero curve (see xr_get_game_quad_extent()'s own
// comment; xr_input.c's xr_input_try_game_quad() hit-tests whichever
// shape is actually submitted here, always agreeing since both read
// the same getter) - 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 screen 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 screen itself (see
// xr_input_get_beam_layer()).
float gameDistance, gameHalfWidth, gameHalfHeight, gameRadius, gameCentralAngle;
xr_get_game_quad_extent(&gameDistance, &gameHalfWidth, &gameHalfHeight, &gameRadius,
&gameCentralAngle);
bool gameScreenCurved = gameCentralAngle > 0.0f;
XrCompositionLayerQuad gameQuad = {XR_TYPE_COMPOSITION_LAYER_QUAD};
gameQuad.space = g_local_space;
gameQuad.eyeVisibility = XR_EYE_VISIBILITY_BOTH;
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_swapchain.height / (float)g_game_swapchain.width;
XrCompositionLayerCylinderKHR gameCylinder = {XR_TYPE_COMPOSITION_LAYER_CYLINDER_KHR};
if (gameScreenCurved) {
gameCylinder.space = g_play_space;
gameCylinder.eyeVisibility = XR_EYE_VISIBILITY_BOTH;
gameCylinder.subImage.swapchain = g_game_swapchain.swapchain;
gameCylinder.subImage.imageRect.extent.width = g_game_swapchain.width;
gameCylinder.subImage.imageRect.extent.height = g_game_swapchain.height;
gameCylinder.pose.orientation.w = 1.0f;
gameCylinder.pose.position.z = -gameDistance;
gameCylinder.radius = gameRadius;
gameCylinder.centralAngle = gameCentralAngle;
gameCylinder.aspectRatio = (float)g_game_swapchain.width / (float)g_game_swapchain.height;
} else {
gameQuad.space = g_play_space;
gameQuad.eyeVisibility = XR_EYE_VISIBILITY_BOTH;
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 = -gameDistance;
gameQuad.size.width = gameHalfWidth * 2.0f;
gameQuad.size.height = gameHalfHeight * 2.0f;
}
const XrCompositionLayerBaseHeader *layers[5];
uint32_t layerCount = 0;
if (g_have_acquired_game_image)
layers[layerCount++] = (XrCompositionLayerBaseHeader *)&gameQuad;
layers[layerCount++] = gameScreenCurved ? (XrCompositionLayerBaseHeader *)&gameCylinder
: (XrCompositionLayerBaseHeader *)&gameQuad;
XrCompositionLayerQuad menuQuad;
if (g_frame_should_render && xr_overlay_render_and_build_layer(g_menu_overlay, &menuQuad)) {
menuQuad.space = g_local_space;
menuQuad.space = g_play_space;
menuQuad.eyeVisibility = XR_EYE_VISIBILITY_BOTH;
layers[layerCount++] = (XrCompositionLayerBaseHeader *)&menuQuad;
}
@@ -403,7 +758,7 @@ void xr_frame_end(void) {
XrCompositionLayerQuad keyboardQuad;
if (g_frame_should_render &&
xr_overlay_render_and_build_layer(g_keyboard_overlay, &keyboardQuad)) {
keyboardQuad.space = g_local_space;
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
@@ -423,7 +778,7 @@ void xr_frame_end(void) {
XrCompositionLayerQuad beamQuad[2];
for (int hand = 0; hand < 2; hand++) {
if (xr_input_get_beam_layer(hand, &beamQuad[hand])) {
beamQuad[hand].space = g_local_space;
beamQuad[hand].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];
@@ -435,7 +790,10 @@ void xr_frame_end(void) {
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) {
@@ -445,6 +803,10 @@ void xr_shutdown(void) {
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_overlay_destroy(g_menu_overlay);
@@ -462,11 +824,69 @@ 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;
g_cylinder_layer_supported = false;
// g_screen_width_m/g_screen_curve_deg/g_screen_distance_m deliberately
// NOT reset here - same reasoning as xr_session_set_player_transform()'s
// own state: it's meant to persist across a shutdown/reinit within the
// same process (e.g. an Android lifecycle pause/resume), not just
// within one XR session.
}
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_swapchain.height / (float)g_game_swapchain.width;
void xr_session_set_game_screen_shape(float width_m, float curve_deg, float distance_m) {
g_screen_width_m = width_m;
g_screen_curve_deg = curve_deg;
g_screen_distance_m = distance_m;
}
// Single source of truth for the game screen's current shape, read by
// both xr_frame_end() (to build/submit the actual layer) and
// xr_input_try_game_quad() (xr_input.c, to hit-test the laser pointer
// against it). half_width_m is meaningful only when *central_angle_rad is
// 0 (flat); a curved screen's horizontal extent is bounded by
// *central_angle_rad instead, with *half_height_m always the vertical
// bound either way.
void xr_get_game_quad_extent(float *distance_m, float *half_width_m, float *half_height_m,
float *radius_m, float *central_angle_rad) {
float curveDeg = g_cylinder_layer_supported ? g_screen_curve_deg : 0.0f;
float aspect = (float)g_game_swapchain.height / (float)g_game_swapchain.width;
*distance_m = g_screen_distance_m;
*half_width_m = g_screen_width_m * 0.5f;
*half_height_m = g_screen_width_m * 0.5f * aspect;
if (curveDeg > 0.0f) {
float theta = curveDeg * QS_DEG_TO_RAD;
*central_angle_rad = theta;
*radius_m = g_screen_width_m / theta;
} else {
*central_angle_rad = 0.0f;
*radius_m = 0.0f;
}
}
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;
}
+92 -6
View File
@@ -62,17 +62,103 @@ bool xr_frame_begin(void);
// 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 local reference space's origin, then ends the
// XR frame.
// 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 screen 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);
// laser pointer always matches whatever's actually visible. *radius_m/
// *central_angle_rad are 0 when flat, in which case *half_width_m is the
// horizontal extent; otherwise the screen is a curved cylinder segment
// and the caller uses *central_angle_rad instead of *half_width_m for the
// horizontal bound (*half_height_m stays meaningful either way). Curving
// is currently unused - qs_config.c has no config surface for it - but
// kept working so it can be re-enabled later.
void xr_get_game_quad_extent(float *distance_m, float *half_width_m, float *half_height_m,
float *radius_m, float *central_angle_rad);
// Sets the game screen's current physical width (meters) and curve (wrap
// angle in degrees, 0 = flat) - a plain state setter, no XR calls, same
// category as xr_session_set_player_transform() above (safe to call
// before xr_init() has even run). Read by both xr_frame_end() (to build/
// submit the actual layer) and, via xr_get_game_quad_extent() above,
// xr_input.c's hit-testing. Driven by qs_config.c's
// qs_screen_profile_notify_loop_mode(), which decides which of the
// "Initial Screen"/"Game Screen" config profiles (see CONFIG.md) is
// currently active - this function itself has no notion of "profiles",
// only "what shape is current".
void xr_session_set_game_screen_shape(float width_m, float curve_deg, float distance_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
}
+2 -2
View File
@@ -146,10 +146,10 @@ bool xr_swapchain_acquire(XrInstance instance, XrSwapchainState *state) {
// gl4es's own (linked, not dlsym'd) bind - this targets an FBO id gl4es
// itself created (see xr_swapchain_create()), so its own "current FBO"
// bookkeeping updates correctly and its immediate-mode draw calls
// (android_draw_surface_as_quad(), the laser reticle) land in the right
// place.
// (android_draw_surface_as_quad()) land in the right place.
glBindFramebuffer(GL_FRAMEBUFFER, state->fbos[imageIndex]);
glViewport(0, 0, state->width, state->height);
return true;
}
@@ -4,6 +4,7 @@ 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;
@@ -17,13 +18,19 @@ 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. Each key press is forwarded
* straight to the game as real input (see handleKeyPress()/
* handleCharKeyPress()) - non-printable keys as an
* 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, and printable keys
* via a synthesized SDL_TEXTINPUT event pushed from native code (see
* commitPrintableChar()/nativeSendPrintableChar() in questshock_native.c) -
* 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
@@ -52,11 +59,12 @@ public class KeyboardOverlay extends OverlayPanel {
private static final int TYPED_TEXT_HEIGHT = 100;
// Height of the title bar (buildTitleBar()) and width of its Close
// button - xr_input.c's TITLE_BAR_V_FRACTION/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.
// 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;
@@ -69,6 +77,17 @@ public class KeyboardOverlay extends OverlayPanel {
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
@@ -93,9 +112,9 @@ public class KeyboardOverlay extends OverlayPanel {
// 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 handleCharKeyPress()) -
// the unmodified path goes through commitPrintableChar() instead, which
// doesn't need an Android keycode at all.
// 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;
@@ -135,6 +154,7 @@ public class KeyboardOverlay extends OverlayPanel {
@Override
protected View buildContent() {
charKeys = new ArrayList<>();
currentLanguage = MenuOverlay.nativeGetLanguage();
LinearLayout panel = new LinearLayout(activity);
panel.setOrientation(LinearLayout.VERTICAL);
@@ -207,26 +227,31 @@ public class KeyboardOverlay extends OverlayPanel {
// 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 TITLE_BAR_V_FRACTION/CLOSE_BUTTON_U_FRACTION.
// 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);
bar.setBackgroundColor(0xFF3A3A3A);
// Matches MenuOverlay's own header bar style.
bar.setBackgroundColor(0xFF000080);
TextView label = new TextView(activity);
label.setText("Keyboard - drag here to move");
label.setTextColor(0xFFFFFFFF);
label.setTextSize(TypedValue.COMPLEX_UNIT_SP, 22);
label.setGravity(Gravity.CENTER_VERTICAL);
label.setPadding(24, 0, 0, 0);
bar.addView(label, new LinearLayout.LayoutParams(
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));
Button close = new Button(activity);
close.setText("Close");
close.setOnClickListener(v -> handleKeyPress("Close"));
close.setOnTouchListener(CLICK_ON_TOUCH_UP);
bar.addView(close,
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;
@@ -234,9 +259,10 @@ public class KeyboardOverlay extends OverlayPanel {
// 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 handleKeyPress()) and CharKey (a Shift-sensitive character
// key, handled by handleCharKeyPress()) per item, so a row can match
// real keyboard geometry (e.g. "Tab" followed by a run of CharKeys).
// 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()).
@@ -245,13 +271,15 @@ public class KeyboardOverlay extends OverlayPanel {
row.setOrientation(LinearLayout.HORIZONTAL);
for (Object item : items) {
Button key = new Button(activity);
key.setOnTouchListener(CLICK_ON_TOUCH_UP);
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.setOnClickListener(v -> handleCharKeyPress(charKey));
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.
@@ -259,8 +287,19 @@ public class KeyboardOverlay extends OverlayPanel {
} else {
String label = (String) item;
key.setText(label);
key.setOnClickListener(v -> handleKeyPress(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")) {
@@ -276,6 +315,54 @@ public class KeyboardOverlay extends OverlayPanel {
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")
@@ -310,38 +397,31 @@ public class KeyboardOverlay extends OverlayPanel {
}
}
// "Close" (the title bar's button - see buildTitleBar()) hides this
// overlay's quad entirely (see nativeRequestClose()) and never reaches
// the game. Shift/Ctrl/Alt are handled separately below (Shift is a
// local layer toggle; Ctrl/Alt are one-shot armed modifiers) since
// neither sends a plain key event of its own the way every other
// control key here does. Everything else (Back/Esc/Enter/Tab/arrows/
// F1-F12) is non-printable, so it's forwarded as a real Android key
// event via controlKeyCode() above, then releases any armed Ctrl/Alt
// modifier (this key just consumed it). 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 handleKeyPress(String label) {
if (label.equals("Close")) {
nativeRequestClose();
return;
}
// 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;
}
if (label.equals("Ctrl") || label.equals("Alt")) {
toggleModifier(label);
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);
SDLActivity.onNativeKeyUp(keyCode);
releaseArmedModifiers();
if (label.equals("Back")) {
if (typedText.length() > 0) {
@@ -353,31 +433,45 @@ public class KeyboardOverlay extends OverlayPanel {
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 commitPrintableChar() below like a normal typed
// character; with Ctrl/Alt armed, it instead goes through the same
// onNativeKeyDown()/onNativeKeyUp() path handleKeyPress() uses 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 handleCharKeyPress(CharKey key) {
// 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);
SDLActivity.onNativeKeyUp(key.androidKeyCode);
releaseArmedModifiers();
} else {
commitPrintableChar(c);
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 handleCharKeyPress()/handleKeyPress()) and
// 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
@@ -400,10 +494,10 @@ public class KeyboardOverlay extends OverlayPanel {
button.setText(nowArmed ? "[" + label + "]" : label);
}
// Called after any key actually reaches the game (a control key in
// handleKeyPress(), or a CharKey in handleCharKeyPress()) 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.
// 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);
@@ -429,23 +523,41 @@ public class KeyboardOverlay extends OverlayPanel {
}
}
// Synthesizes the SDL_TEXTINPUT event pump_events() (sdl_events.c)
// requires for printable characters (see handleCharKeyPress() above),
// by building and pushing it directly in native code (see
// nativeSendPrintableChar() in questshock_native.c) rather than through
// Android's IME (there's no real IME session behind this off-screen,
// 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 void commitPrintableChar(char c) {
nativeSendPrintableChar(c);
}
private static native void nativeSendPrintableCharDown(char c);
private static native void nativeSendPrintableChar(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
File diff suppressed because it is too large Load Diff
@@ -1,13 +1,20 @@
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
@@ -59,11 +66,59 @@ abstract class OverlayPanel {
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
@@ -183,9 +238,95 @@ abstract class OverlayPanel {
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,
// additionally subtracting each ancestor's own getScrollX()/getScrollY()
// (0 for a plain ViewGroup, non-zero for e.g. a ScrollView - see
// MenuOverlay's Controller Options tab) so a view inside a scrolled
// container reports its actual on-screen position, not its laid-out one.
// 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;
}
View parent = (View) current.getParent();
left -= parent.getScrollX();
top -= parent.getScrollY();
current = parent;
}
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;
@@ -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;
@@ -41,7 +39,8 @@ import org.libsdl.app.SDLSurface;
public class QuestShockActivity extends SDLActivity {
private static final String TAG = "QuestShock";
private static final int PERMISSION_REQUEST_STORAGE = 1;
private static final String GAME_DIR = "/sdcard/questshock";
// Package-visible: MenuOverlay reads controller_profiles/ from here.
static final String GAME_DIR = "/sdcard/questshock";
// Loaded here (rather than waiting for SDLActivity's own
// getLibraries()/loadLibraries(), which only runs from super.onCreate())
@@ -145,11 +144,25 @@ public class QuestShockActivity extends SDLActivity {
// is already there, assume a previous run already extracted everything.
File marker = new File(gameDir, "GET_ASSETS_QUEST.txt");
if (!marker.exists()) {
copyAssetDir("shaders", new File(gameDir, "shaders"));
copyAssetFile("res/soundfont.sf2", new File(gameDir, "res/soundfont.sf2"));
copyAssetFile("GET_ASSETS_QUEST.txt", marker);
copyAssetDir("shaders", new File(gameDir, "shaders"), false);
copyAssetFile("res/soundfont.sf2", new File(gameDir, "res/soundfont.sf2"), false);
copyAssetFile("GET_ASSETS_QUEST.txt", marker, false);
}
// Refreshed on every launch (unlike the marker-gated block above) so
// it always matches the installed build - qs_config.c reads this as
// the initial config only when questshock.config.json doesn't exist
// yet, never overwriting a user's own saved config.
copyAssetFile("questshock.config.default.json",
new File(gameDir, "questshock.config.default.json"), true);
// Refreshed on every launch. MenuOverlay's Controller Options tab
// matches the active OpenXR interaction profile against these files'
// "interaction_profile" and shows the paired image (see CONFIG.md).
// Only the bundled filenames are overwritten, so profiles a user adds
// to this folder are kept.
copyAssetDir("controller_profiles", new File(gameDir, "controller_profiles"), true);
// Shockolate's engine/ is never patched to check this itself (see the
// class doc above) - it just does plain fopen("res/data/...", ...)
// and, on a fresh install with no game data copied in yet, that
@@ -213,8 +226,8 @@ public class QuestShockActivity extends SDLActivity {
.show();
}
private void copyAssetFile(String assetPath, File dest) {
if (dest.exists()) {
private void copyAssetFile(String assetPath, File dest, boolean overwrite) {
if (dest.exists() && !overwrite) {
return;
}
File parent = dest.getParentFile();
@@ -234,7 +247,7 @@ public class QuestShockActivity extends SDLActivity {
}
}
private void copyAssetDir(String assetDir, File destDir) {
private void copyAssetDir(String assetDir, File destDir, boolean overwrite) {
AssetManager assets = getAssets();
String[] entries;
try {
@@ -248,7 +261,7 @@ public class QuestShockActivity extends SDLActivity {
}
destDir.mkdirs();
for (String entry : entries) {
copyAssetFile(assetDir + "/" + entry, new File(destDir, entry));
copyAssetFile(assetDir + "/" + entry, new File(destDir, entry), overwrite);
}
}
}
@@ -0,0 +1,217 @@
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");
put("initial_screen", "Initial Screen", "Écran initial", "Startbildschirm");
put("game_screen", "Game Screen", "Écran de jeu", "Spielbildschirm");
put("width", "Width", "Largeur", "Breite");
put("distance", "Distance", "Distance", "Abstand");
// "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");
// Controller Options tab: action row labels, keyed by xr_input.c's
// action names (see MenuOverlay.ACTION_KEYS).
put("controller_options", "Controller Options", "Options de manette", "Controller-Optionen");
put("action_move_forward", "Move Forward", "Avancer", "Vorwärts gehen");
put("action_move_back", "Move Back", "Reculer", "Rückwärts gehen");
put("action_strafe_left", "Strafe Left", "Pas latéral gauche", "Seitwärts links");
put("action_strafe_right", "Strafe Right", "Pas latéral droit", "Seitwärts rechts");
put("action_turn_left", "Turn Left", "Tourner à gauche", "Nach links drehen");
put("action_turn_right", "Turn Right", "Tourner à droite", "Nach rechts drehen");
put("action_jump", "Jump", "Sauter", "Springen");
put("action_mouse_click", "Click", "Cliquer", "Klicken");
put("action_mouse_double_click", "Double-Click", "Double-clic", "Doppelklick");
// Hotkey actions (see xr_input.c); all unbound by default.
put("action_stand", "Stand", "Debout", "Stehen");
put("action_crouch", "Crouch", "Accroupi", "Ducken");
put("action_prone", "Prone", "Couché", "Liegen");
put("action_pause", "Pause Game", "Mettre en pause", "Spiel pausieren");
put("action_full_view", "Fullscreen View", "Vue plein écran", "Vollbildansicht");
put("action_normal_view", "Normal View", "Vue normale", "Normale Ansicht");
put("action_map_view", "Automap View", "Vue de la carte", "Automap-Ansicht");
put("action_save_game", "Save Game", "Sauvegarder", "Spiel speichern");
put("action_load_game", "Load Game", "Charger", "Spiel laden");
put("action_select_grenade", "Select Grenade", "Sélectionner une grenade",
"Granate auswählen");
put("action_arm_grenade", "Arm Grenade", "Armer la grenade", "Granate scharfmachen");
put("action_select_drug", "Select Drug", "Sélectionner une drogue", "Droge auswählen");
put("action_use_drug", "Use Drug", "Utiliser la drogue", "Droge benutzen");
put("action_toggle_online_help", "Toggle Online Help", "Basculer l'aide en ligne",
"Online-Hilfe umschalten");
put("action_show_help", "Show Help", "Afficher l'aide", "Hilfe anzeigen");
put("action_toggle_music", "Toggle Music", "Basculer la musique", "Musik umschalten");
put("action_quit", "Quit Game", "Quitter le jeu", "Spiel beenden");
put("action_cycle_weapon_next", "Next Weapon", "Arme suivante", "Nächste Waffe");
put("action_cycle_weapon_prev", "Previous Weapon", "Arme précédente", "Vorherige Waffe");
put("action_cycle_detail", "Cycle Detail Level", "Changer le niveau de détail",
"Detailgrad wechseln");
put("action_reload_weapon_primary", "Reload Weapon (Primary)",
"Recharger l'arme (principal)", "Waffe nachladen (primär)");
put("action_reload_weapon_secondary", "Reload Weapon (Secondary)",
"Recharger l'arme (secondaire)", "Waffe nachladen (sekundär)");
put("action_hud_color", "Cycle HUD Color", "Changer la couleur de l'interface",
"HUD-Farbe wechseln");
put("action_clear_fullscreen", "Clear Fullscreen View", "Quitter la vue plein écran",
"Vollbildansicht schließen");
put("action_mfd_left_1", "Left MFD: Weapon", "MFD gauche : arme", "Linkes MFD: Waffe");
put("action_mfd_left_2", "Left MFD: Item", "MFD gauche : objet", "Linkes MFD: Gegenstand");
put("action_mfd_left_3", "Left MFD: Map", "MFD gauche : carte", "Linkes MFD: Karte");
put("action_mfd_left_4", "Left MFD: Target", "MFD gauche : cible", "Linkes MFD: Ziel");
put("action_mfd_left_5", "Left MFD: Info", "MFD gauche : info", "Linkes MFD: Info");
put("action_mfd_right_1", "Right MFD: Weapon", "MFD droit : arme", "Rechtes MFD: Waffe");
put("action_mfd_right_2", "Right MFD: Item", "MFD droit : objet",
"Rechtes MFD: Gegenstand");
put("action_mfd_right_3", "Right MFD: Map", "MFD droit : carte", "Rechtes MFD: Karte");
put("action_mfd_right_4", "Right MFD: Target", "MFD droit : cible", "Rechtes MFD: Ziel");
put("action_mfd_right_5", "Right MFD: Info", "MFD droit : info", "Rechtes MFD: Info");
put("action_fire_weapon", "Fire Weapon", "Tirer", "Waffe abfeuern");
// Cheats tab.
put("cheats", "Cheats", "Triches", "Cheats");
put("cheats_note", "Cheats need a running game.", "Les triches nécessitent une partie en cours.",
"Cheats benötigen ein laufendes Spiel.");
put("cheat_give_all", "Give All Items & Weapons", "Tout obtenir", "Alles erhalten");
put("cheat_physics", "Physics", "Physique", "Physik");
put("cheat_level_up", "Next Level", "Niveau suivant", "Nächstes Level");
put("cheat_level_down", "Previous Level", "Niveau précédent", "Vorheriges Level");
put("cheat_unlimited_health", "Unlimited Health", "Santé illimitée", "Unbegrenzt Leben");
put("cheat_unlimited_energy", "Unlimited Energy", "Énergie illimitée", "Unbegrenzt Energie");
put("cheat_unlimited_ammo", "Unlimited Ammo", "Munitions illimitées", "Unbegrenzt Munition");
put("cheat_unlimited_items", "Unlimited Items", "Objets illimités", "Unbegrenzt Items");
put("action_free_left_thumbstick_view", "Free View (Left Stick)",
"Vue libre (stick gauche)", "Freie Ansicht (linker Stick)");
put("action_free_right_thumbstick_view", "Free View (Right Stick)",
"Vue libre (stick droit)", "Freie Ansicht (rechter Stick)");
// Cyberspace actions (active only in cyberspace, see xr_input.c).
put("action_cyber_thrust", "Cyberspace: Thrust", "Cyberespace : poussée",
"Cyberspace: Schub");
put("action_cyber_climb", "Cyberspace: Climb", "Cyberespace : monter",
"Cyberspace: Steigen");
put("action_cyber_dive", "Cyberspace: Dive", "Cyberespace : plonger",
"Cyberspace: Sinken");
put("action_cyber_bank_left", "Cyberspace: Bank Left", "Cyberespace : virage à gauche",
"Cyberspace: Links kippen");
put("action_cyber_bank_right", "Cyberspace: Bank Right", "Cyberespace : virage à droite",
"Cyberspace: Rechts kippen");
put("action_cyber_roll_left", "Cyberspace: Roll Left", "Cyberespace : roulis gauche",
"Cyberspace: Links rollen");
put("action_cyber_roll_right", "Cyberspace: Roll Right", "Cyberespace : roulis droit",
"Cyberspace: Rechts rollen");
put("action_cyber_climb_left", "Cyberspace: Climb Left", "Cyberespace : monter à gauche",
"Cyberspace: Links steigen");
put("action_cyber_climb_right", "Cyberspace: Climb Right", "Cyberespace : monter à droite",
"Cyberspace: Rechts steigen");
put("action_cyber_dive_left", "Cyberspace: Dive Left", "Cyberespace : plonger à gauche",
"Cyberspace: Links sinken");
put("action_cyber_dive_right", "Cyberspace: Dive Right", "Cyberespace : plonger à droite",
"Cyberspace: Rechts sinken");
put("capture_prompt", "Press a button…", "Appuyez sur un bouton…", "Taste drücken…");
put("reset_to_defaults", "Reset to Defaults", "Réinitialiser", "Auf Standard zurücksetzen");
put("generic_profile_note",
"This controller only exposes a trigger and a menu button - most actions above have "
+ "nothing to bind to.",
"Cette manette n'expose qu'une gâchette et un bouton menu - la plupart des actions "
+ "ci-dessus n'ont rien à quoi être assignées.",
"Dieser Controller bietet nur einen Trigger und eine Menütaste - die meisten "
+ "Aktionen oben haben nichts, woran sie gebunden werden können.");
// Raw physical-input display names (xr_input.c's kRawInputNames);
// "none" is the unbound sentinel.
put("raw_left_trigger", "Left Trigger", "Gâchette gauche", "Linker Trigger");
put("raw_right_trigger", "Right Trigger", "Gâchette droite", "Rechter Trigger");
put("raw_left_squeeze", "Left Grip", "Préhension gauche", "Linker Griff");
put("raw_right_squeeze", "Right Grip", "Préhension droite", "Rechter Griff");
put("raw_left_x", "X Button", "Bouton X", "X-Taste");
put("raw_left_y", "Y Button", "Bouton Y", "Y-Taste");
put("raw_right_a", "A Button", "Bouton A", "A-Taste");
put("raw_right_b", "B Button", "Bouton B", "B-Taste");
put("raw_left_thumbstick_click", "Left Stick Click", "Clic du stick gauche",
"Linker Stick-Klick");
put("raw_right_thumbstick_click", "Right Stick Click", "Clic du stick droit",
"Rechter Stick-Klick");
put("raw_left_thumbstick_up", "Left Stick Up", "Stick gauche haut", "Linker Stick hoch");
put("raw_left_thumbstick_down", "Left Stick Down", "Stick gauche bas", "Linker Stick runter");
put("raw_left_thumbstick_left", "Left Stick Left", "Stick gauche à gauche",
"Linker Stick links");
put("raw_left_thumbstick_right", "Left Stick Right", "Stick gauche à droite",
"Linker Stick rechts");
put("raw_right_thumbstick_up", "Right Stick Up", "Stick droit haut", "Rechter Stick hoch");
put("raw_right_thumbstick_down", "Right Stick Down", "Stick droit bas",
"Rechter Stick runter");
put("raw_right_thumbstick_left", "Right Stick Left", "Stick droit à gauche",
"Rechter Stick links");
put("raw_right_thumbstick_right", "Right Stick Right", "Stick droit à droite",
"Rechter Stick rechts");
put("raw_trigger", "Either Trigger", "L'une ou l'autre gâchette", "Beliebiger Trigger");
put("raw_squeeze", "Either Grip", "L'une ou l'autre préhension", "Beliebiger Griff");
put("raw_none", "(unbound)", "(non assigné)", "(nicht zugewiesen)");
}
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];
}
}
+12 -1
View File
@@ -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,30 @@
--- a/src/GameSrc/mainloop.c
+++ b/src/GameSrc/mainloop.c
@@ -57,6 +57,10 @@
#include "tools.h"
#include "wrapper.h"
+#ifdef __ANDROID__
+#include "qs_config.h"
+#endif
+
// how is the game doing, anyway, set to true at end of time
uchar cit_success = FALSE;
@@ -103,6 +107,16 @@
_last_mode = *cmode;
(*exit_modes[_last_mode])();
*cmode = _new_mode;
+#ifdef __ANDROID__
+ // Notifies qs_config.c of every loop-mode transition so it can pick
+ // between the "Initial Screen" and "Game Screen" virtual-screen
+ // presets - see qs_config.h/CONFIG.md. Called synchronously, directly,
+ // on this thread (mainloop()'s own) - unlike the UI-thread-triggered
+ // config actions elsewhere in qs_config.c, this doesn't need the
+ // cross-thread SDL-event dance since it already originates on the
+ // right thread.
+ qs_screen_profile_notify_loop_mode(*cmode);
+#endif
_static_change = 0;
if (*cmode >= 0)
(*enter_modes[*cmode])();
@@ -0,0 +1,42 @@
--- a/src/GameSrc/cutsloop.c
+++ b/src/GameSrc/cutsloop.c
@@ -292,11 +292,38 @@
+#ifdef __ANDROID__
+// The intro (cutscene 0) ships in one file per language - localized
+// voice-over - each holding the same resource id (cutscene_anims[0]), so
+// opening the current language's file instead is all it takes. The death
+// and ending cutscenes only have one version.
+static char *intro_files_by_language[3] =
+{
+ "res/data/svgaintr.res", // English
+ "res/data/svfrintr.res", // French
+ "res/data/svgeintr.res" // German
+};
+
+static int open_cutscene_file(int id)
+{
+ if (id == 0 && which_lang > 0 && which_lang < 3)
+ {
+ int handle = ResOpenFile(intro_files_by_language[(int)which_lang]);
+ if (handle > 0)
+ return handle;
+ // Not installed for this language - fall back to English below.
+ }
+ return ResOpenFile(cutscene_files[id]);
+}
+#else
+#define open_cutscene_file(id) ResOpenFile(cutscene_files[id])
+#endif
+
short play_cutscene(int id, bool show_credits)
{
MacTuneKillCurrentTheme();
- cutscene_filehandle = ResOpenFile(cutscene_files[id]);
+ cutscene_filehandle = open_cutscene_file(id);
if (cutscene_filehandle <= 0) {
// If we failed to play the cutscene, go to setup / credits.
_new_mode = SETUP_LOOP;
@@ -0,0 +1,13 @@
--- a/src/GameSrc/mouselook.c
+++ b/src/GameSrc/mouselook.c
@@ -121,3 +121,10 @@
get_mouselook_vel(&mvelx, &mvely);
}
}
+
+#ifdef __ANDROID__
+// Whether the player is currently in cyberspace; read by the Android
+// controller input code (android/app/src/main/cpp/xr_input.c) to switch
+// between the normal and cyberspace action sets.
+int qs_in_cyberspace(void) { return global_fullmap != NULL && global_fullmap->cyber; }
+#endif
@@ -0,0 +1,110 @@
--- a/src/GameSrc/movekeys.c
+++ b/src/GameSrc/movekeys.c
@@ -44,6 +44,12 @@
extern bool gKeypadOverride;
uchar parse_motion_key(ushort code, short *cnum, short *cval);
+#ifdef __ANDROID__
+// Set every frame by android/app/src/main/cpp/xr_input.c.
+float qs_cyber_pitch_scale = 1.0f;
+float qs_cyber_bank_scale = 1.0f;
+#endif
+
uchar parse_motion_key_cyber(ushort code, short *cnum, short *cval);
void init_motion_polling(void);
@@ -173,6 +179,20 @@
*cnum = -1;
*cval = 0;
+#ifdef __ANDROID__
+ // Pitch and bank strength, scaled by the controller thumbstick's
+ // deflection (see android/app/src/main/cpp/xr_input.c); 1.0 for a key.
+ short pitchMax = (short)(CONTROL_MAX_VAL * qs_cyber_pitch_scale + 0.5f);
+ short bankMax = (short)(CONTROL_MAX_VAL * qs_cyber_bank_scale + 0.5f);
+ if (pitchMax < 1)
+ pitchMax = 1;
+ if (bankMax < 1)
+ bankMax = 1;
+#else
+ short pitchMax = CONTROL_MAX_VAL;
+ short bankMax = CONTROL_MAX_VAL;
+#endif
+
while (MoveCyberKeybinds[i].code != 255) {
if (code == MoveCyberKeybinds[i].code) {
move = MoveCyberKeybinds[i].move;
@@ -189,22 +209,22 @@
case M_CLIMB:
*cnum = CONTROL_YVEL;
- *cval = -CONTROL_MAX_VAL;
+ *cval = -pitchMax;
break;
case M_BANKLEFT:
*cnum = CONTROL_XYROT;
- *cval = -CONTROL_MAX_VAL;
+ *cval = -bankMax;
break;
case M_BANKRIGHT:
*cnum = CONTROL_XYROT;
- *cval = CONTROL_MAX_VAL;
+ *cval = bankMax;
break;
case M_DIVE:
*cnum = CONTROL_YVEL;
- *cval = CONTROL_MAX_VAL;
+ *cval = pitchMax;
break;
case M_ROLLRIGHT:
@@ -219,38 +239,38 @@
case M_CLIMBLEFT:
*cnum = CONTROL_YVEL;
- *cval = -CONTROL_MAX_VAL;
+ *cval = -pitchMax;
if (abs(poll_controls[*cnum]) < abs(*cval))
poll_controls[*cnum] = *cval;
*cnum = CONTROL_XYROT;
- *cval = -CONTROL_MAX_VAL;
+ *cval = -bankMax;
break;
case M_CLIMBRIGHT:
*cnum = CONTROL_YVEL;
- *cval = -CONTROL_MAX_VAL;
+ *cval = -pitchMax;
if (abs(poll_controls[*cnum]) < abs(*cval))
poll_controls[*cnum] = *cval;
*cnum = CONTROL_XYROT;
- *cval = CONTROL_MAX_VAL;
+ *cval = bankMax;
break;
case M_DIVERIGHT:
*cnum = CONTROL_YVEL;
- *cval = CONTROL_MAX_VAL;
+ *cval = pitchMax;
if (abs(poll_controls[*cnum]) < abs(*cval))
poll_controls[*cnum] = *cval;
*cnum = CONTROL_XYROT;
- *cval = CONTROL_MAX_VAL;
+ *cval = bankMax;
break;
case M_DIVELEFT:
*cnum = CONTROL_YVEL;
- *cval = CONTROL_MAX_VAL;
+ *cval = pitchMax;
if (abs(poll_controls[*cnum]) < abs(*cval))
poll_controls[*cnum] = *cval;
*cnum = CONTROL_XYROT;
- *cval = -CONTROL_MAX_VAL;
+ *cval = -bankMax;
break;
default:
// Unhandled movement. What I gonna do?
@@ -0,0 +1,74 @@
--- a/src/GameSrc/hkeyfunc.c
+++ b/src/GameSrc/hkeyfunc.c
@@ -1465,3 +1465,71 @@
#pragma enable_message(202)
#endif // NOT_YET
+
+#ifdef __ANDROID__
+// Tops up parts of the player's state; android/app/src/main/cpp/qs_config.c
+// calls it once per frame with the "unlimited" cheats that are switched on.
+// `what` is a bitmask: 1 health, 2 energy, 4 ammo, 8 items (the same values as
+// QS_CHEAT_MASK_* in qs_config.c). Only conventional guns use ammo: beam and
+// melee weapons keep heat in the same field, so they are left alone (beams draw
+// on energy). Spare ammo clips, grenades and drugs are only refilled for kinds
+// the player already holds, so the cheat never creates items.
+void qs_cheat_refill(int what) {
+ const int kAmmoFloor = 99;
+ const int kItemFloor = 99;
+ uchar vitalsChanged = FALSE;
+ uchar inventoryChanged = FALSE;
+
+ if (player_struct.dead)
+ return;
+ if ((what & 1) && player_struct.hit_points < PLAYER_MAX_HP) {
+ player_struct.hit_points = PLAYER_MAX_HP;
+ vitalsChanged = TRUE;
+ }
+ if ((what & 1) && player_struct.cspace_hp < PLAYER_MAX_HP) {
+ player_struct.cspace_hp = PLAYER_MAX_HP;
+ vitalsChanged = TRUE;
+ }
+ if ((what & 2) && player_struct.energy < 255) {
+ player_struct.energy = 255;
+ vitalsChanged = TRUE;
+ }
+ if (vitalsChanged)
+ chg_set_flg(VITALS_UPDATE);
+
+ if (what & 4) {
+ for (int i = 0; i < NUM_WEAPON_SLOTS; i++) {
+ weapon_slot *slot = &player_struct.weapons[i];
+ if (slot->type != EMPTY_WEAPON_SLOT && slot->type <= GUN_SUBCLASS_SPECIAL &&
+ slot->ammo < kAmmoFloor) {
+ slot->ammo = kAmmoFloor;
+ inventoryChanged = TRUE;
+ }
+ }
+ for (int i = 0; i < NUM_AMMO_TYPES; i++) {
+ if (player_struct.cartridges[i] > 0 && player_struct.cartridges[i] < kItemFloor) {
+ player_struct.cartridges[i] = kItemFloor;
+ inventoryChanged = TRUE;
+ }
+ }
+ }
+ if (what & 8) {
+ for (int i = 0; i < NUM_GRENADEZ; i++) {
+ if (player_struct.grenades[i] > 0 && player_struct.grenades[i] < kItemFloor) {
+ player_struct.grenades[i] = kItemFloor;
+ inventoryChanged = TRUE;
+ }
+ }
+ for (int i = 0; i < NUM_DRUGZ; i++) {
+ if (player_struct.drugs[i] > 0 && player_struct.drugs[i] < kItemFloor) {
+ player_struct.drugs[i] = kItemFloor;
+ inventoryChanged = TRUE;
+ }
+ }
+ }
+ if (inventoryChanged) {
+ chg_set_flg(INVENTORY_UPDATE);
+ mfd_force_update();
+ }
+}
+#endif
+28
View File
@@ -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."
+4 -3
View File
@@ -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
+113 -3
View File
@@ -18,6 +18,13 @@ ARG FLUIDSYNTH_LITE_REF=c539a8d9270ba5a3f7d6e460606483fc2ab1eb61
# Soundfont used for MIDI music, matching what engine/build_deps.sh itself
# fetches (a free substitute for the Windows default GM soundfont).
ARG SOUNDFONT_URL=http://rancid.kapsi.fi/windows.sf2
# GLEW, for the Windows/MinGW cross-build only (engine/CMakeLists.txt's
# WIN32 branch - Windows' own opengl32.dll only exposes OpenGL 1.1, so
# anything newer needs GLEW's runtime extension loading; Linux instead
# gets modern prototypes straight from Mesa's headers, no loader needed -
# see engine/src/MacSrc/OpenGL.cc). Matches the version engine/'s own
# upstream Windows build script (build_win64.sh) used.
ARG GLEW_VERSION=2.1.0
# Gitea/GitHub Actions' JS-based actions (actions/checkout,
# actions/upload-artifact, ...) need a node binary in the container job's
# PATH - this image is otherwise pure C toolchain, so it isn't pulled in
@@ -99,6 +106,15 @@ ENV DEBIAN_FRONTEND=noninteractive
# openssh-client: the CI workflow's `sftp` publish step.
# openjdk-17-jdk-headless: Gradle/AGP's own minimum JDK for the APK build.
# unzip: extracts the Android cmdline-tools zip below.
# mingw-w64: the x86_64-w64-mingw32-{gcc,g++,windres,ar,...} cross
# toolchain for the Windows desktop build (see the "Windows cross-compile"
# section below). Ubuntu ships both a win32-thread-model and a
# posix-thread-model variant behind update-alternatives; the default
# (win32) is fine here since nothing in engine/ uses std::thread.
# zip: `make package-win`'s Windows release archive - a CI job's own
# `make package` runs inside this image as its container (see
# .gitea/workflows/build.yml), so it needs to be baked in here, not just
# available on a local dev machine's own host (see README).
#
# All apt installs deliberately live in this one RUN, first, so editing
# anything below it (in particular the Android cross-compile steps, the
@@ -111,7 +127,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libgl1-mesa-dev libglx-dev libxext-dev libx11-dev libxrandr-dev \
libxi-dev libxfixes-dev libxss-dev libxinerama-dev libxcursor-dev \
libogg-dev libvorbis-dev libasound2-dev openssh-client \
openjdk-17-jdk-headless unzip \
openjdk-17-jdk-headless unzip mingw-w64 zip \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /opt/prebuilt
@@ -150,10 +166,96 @@ RUN git clone https://github.com/EtherTyper/fluidsynth-lite.git \
&& rm -rf .git
# General MIDI soundfont for fluidsynth playback - engine/build_deps.sh
# fetches the same file and drops it into engine/res/.
# fetches the same file and drops it into engine/res/. Shared by both the
# Linux and Windows builds below.
RUN mkdir -p soundfont \
&& curl -sSL -o soundfont/default.sf2 "${SOUNDFONT_URL}"
# CMake toolchain file for the Windows/MinGW cross-build below, reused at
# container-run time by build-image/build-engine-win.sh (see
# MINGW_TOOLCHAIN_FILE) to cross-compile engine/ itself the same way. GCC's
# runtime (libgcc/libstdc++) is linked statically so only the SDL2/
# SDL2_mixer/GLEW/fluidsynth-lite DLLs (plus libwinpthread, which isn't
# safe to static-link the same way) need shipping alongside systemshock.exe.
ENV MINGW_TOOLCHAIN_FILE=/opt/mingw-toolchain.cmake
RUN printf '%s\n' \
'set(CMAKE_SYSTEM_NAME Windows)' \
'set(CMAKE_SYSTEM_PROCESSOR x86_64)' \
'set(CMAKE_C_COMPILER x86_64-w64-mingw32-gcc)' \
'set(CMAKE_CXX_COMPILER x86_64-w64-mingw32-g++)' \
'set(CMAKE_RC_COMPILER x86_64-w64-mingw32-windres)' \
'set(CMAKE_FIND_ROOT_PATH /usr/x86_64-w64-mingw32)' \
'# LIBRARY/INCLUDE/PACKAGE deliberately left at CMake'"'"'s own' \
'# cross-compiling default (BOTH) - engine/CMakeLists.txt'"'"'s' \
'# BUNDLED SDL2/SDL2_mixer/FluidSynth find_library() calls point at' \
'# build_ext/ (outside this sysroot entirely), which ONLY would' \
'# refuse to search.' \
'set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)' \
'set(CMAKE_EXE_LINKER_FLAGS_INIT "-static-libgcc -static-libstdc++")' \
> "${MINGW_TOOLCHAIN_FILE}"
# SDL2 and SDL2_mixer for Windows: unlike the desktop build above, these
# are libsdl.org's own official prebuilt MinGW devel packages (headers +
# import libs + DLLs for x86_64-w64-mingw32), not built from source here -
# SDL2's autotools setup targets *nix; upstream ships MinGW builds
# pre-made instead, same as engine/'s own upstream Windows build script
# (build_win64.sh) uses. Same version pins as the Linux build above, so
# both desktop builds ship the same SDL2/SDL2_mixer release.
RUN curl -sSLO "https://www.libsdl.org/release/SDL2-devel-${SDL2_VERSION}-mingw.tar.gz" \
&& tar xf "SDL2-devel-${SDL2_VERSION}-mingw.tar.gz" \
&& mkdir -p /opt/prebuilt/win \
&& mv "SDL2-${SDL2_VERSION}/x86_64-w64-mingw32" /opt/prebuilt/win/sdl2 \
&& rm -rf "SDL2-${SDL2_VERSION}" "SDL2-devel-${SDL2_VERSION}-mingw.tar.gz"
RUN curl -sSLO "https://www.libsdl.org/projects/SDL_mixer/release/SDL2_mixer-devel-${SDL2_MIXER_VERSION}-mingw.tar.gz" \
&& tar xf "SDL2_mixer-devel-${SDL2_MIXER_VERSION}-mingw.tar.gz" --exclude=Xcode \
&& mv "SDL2_mixer-${SDL2_MIXER_VERSION}/x86_64-w64-mingw32" /opt/prebuilt/win/sdl2_mixer \
&& rm -rf "SDL2_mixer-${SDL2_MIXER_VERSION}" "SDL2_mixer-devel-${SDL2_MIXER_VERSION}-mingw.tar.gz"
# GLEW for Windows: compiled directly instead of via GLEW's own bundled
# cross-compile Makefile configs (config/Makefile.linux-mingw64 et al) -
# those pass raw `-soname`/`--out-implib` straight to whatever $(LD) is
# set to, which only works if LD is the real `ld` binary, not gcc-as-
# linker-driver (and that config also hardcodes a 32-bit `i686-w64-
# mingw32` host despite the "64" in its name) - simpler and more robust
# to just compile+link GLEW's one source file ourselves. Produces
# glew32.dll (to ship alongside systemshock.exe) and libglew32.dll.a (the
# MinGW import library engine/CMakeLists.txt's WIN32 branch links
# against).
RUN curl -sSL -o "glew-${GLEW_VERSION}.tgz" \
"https://sourceforge.net/projects/glew/files/glew/${GLEW_VERSION}/glew-${GLEW_VERSION}.tgz/download" \
&& tar xf "glew-${GLEW_VERSION}.tgz" \
&& cd "glew-${GLEW_VERSION}" \
&& mkdir -p /opt/prebuilt/win/glew/include/GL /opt/prebuilt/win/glew/lib \
&& x86_64-w64-mingw32-gcc -DGLEW_NO_GLU -O2 -Iinclude -c src/glew.c -o glew.o \
&& x86_64-w64-mingw32-gcc -shared \
-Wl,--out-implib,/opt/prebuilt/win/glew/lib/libglew32.dll.a \
-o /opt/prebuilt/win/glew/lib/glew32.dll \
glew.o -lopengl32 -lgdi32 -luser32 -lkernel32 \
&& cp include/GL/glew.h include/GL/wglew.h /opt/prebuilt/win/glew/include/GL/ \
&& cd .. && rm -rf "glew-${GLEW_VERSION}" "glew-${GLEW_VERSION}.tgz"
# fluidsynth-lite for Windows: same source/ref/DLL-mode patch as the
# desktop build above, cross-compiled via the MinGW toolchain file. WIN32
# skips fluidsynth-lite's own pthread dependency (see its CMakeLists.txt),
# so no libwinpthread linkage to worry about here.
RUN git clone https://github.com/EtherTyper/fluidsynth-lite.git fluidsynth-lite-win \
&& cd fluidsynth-lite-win \
&& git checkout "${FLUIDSYNTH_LITE_REF}" \
&& sed -i 's/DLL"\ off/DLL"\ on/' CMakeLists.txt \
&& rm -rf .git \
&& cd .. \
&& cmake -S fluidsynth-lite-win -B build-fluidsynth-win \
-DCMAKE_TOOLCHAIN_FILE="${MINGW_TOOLCHAIN_FILE}" \
&& cmake --build build-fluidsynth-win -j"$(nproc)" \
&& mkdir -p /opt/prebuilt/win/fluidsynth-lite/lib /opt/prebuilt/win/fluidsynth-lite/include \
&& cp build-fluidsynth-win/src/*.dll build-fluidsynth-win/src/*.dll.a \
/opt/prebuilt/win/fluidsynth-lite/lib/ \
&& cp -a fluidsynth-lite-win/include/. /opt/prebuilt/win/fluidsynth-lite/include/ \
&& cp build-fluidsynth-win/include/fluidsynth/version.h \
/opt/prebuilt/win/fluidsynth-lite/include/fluidsynth/version.h \
&& rm -rf fluidsynth-lite-win build-fluidsynth-win
# Node.js: needed only so Gitea/GitHub Actions' JS-based actions can run
# when this image is used as a CI job's container - see NODE_VERSION above.
RUN curl -sSL -o /tmp/node.tar.xz \
@@ -283,7 +385,15 @@ RUN git clone --branch "release-${ANDROID_OPENXR_VERSION}" --depth 1 \
COPY build-image/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
COPY build-image/build-engine.sh /usr/local/bin/build-engine.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh /usr/local/bin/build-engine.sh
# 755, not +x: the entrypoint drops to a `builder` user matching the
# *host's* UID/GID (see docker-entrypoint.sh), which is never in these
# root-owned files' group, so it needs the image's own explicit
# world-read+execute here - `chmod +x` alone only adds execute bits on
# top of whatever "other" permissions the source file happened to have
# (which depends on the host's umask at checkout, e.g. a restrictive
# 0007 umask yields unreadable-by-other files, which then round-trip
# into the image and cause a Permission denied at container run time).
RUN chmod 755 /usr/local/bin/docker-entrypoint.sh /usr/local/bin/build-engine.sh
# Marks a shell as already running inside this image (with every engine
# build dependency prebuilt above) - lets the Makefile's `engine` target
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env bash
# Cross-compiles engine/ (the vendored Shockolate snapshot) for Windows
# (x86_64) via MinGW, against the dependencies prebuilt into this image at
# /opt/prebuilt/win - no network access needed. engine/CMakeLists.txt
# already has working WIN32/MINGW branches (the upstream Shockolate
# project's own build_win64.sh/appveyor.yml build the same way, natively
# on Windows via Git Bash/MinGW; this cross-compiles the same branches
# fully offline and reproducibly from Linux instead), so no source
# patching is needed, unlike the Quest build's android/engine-patches/.
#
# Builds from a scratch copy of engine/ (like android/engine-patches/
# does for the Quest build) rather than engine/ itself: its BUNDLED
# dependency paths (build_ext/built_sdl etc.) and in-source CMake cache
# are relative to/inside the source tree, and would otherwise collide
# with the Linux desktop build's own build_ext/CMakeCache.txt (see
# build-engine.sh) if both are built from the same checkout, as `make
# dist` (dist-linux + dist-win) does.
#
# Must be run with the repo root as the working directory - either via
# ../run-image.sh, or directly when already inside this image (see the
# Makefile's `engine-win` target, which picks whichever of these
# applies, same as `engine`).
#
# Output lands in engine/.build-output-win/: systemshock.exe, the DLLs
# it needs at runtime, and a default MIDI soundfont - everything the
# root Makefile needs to assemble dist-win/.
set -euo pipefail
REPO_ROOT="$(pwd)"
SCRATCH_ENGINE="$REPO_ROOT/build/win-engine"
OUT_DIR="$REPO_ROOT/engine/.build-output-win"
echo "== Preparing a scratch copy of engine/ for the Windows cross-build =="
rm -rf "$SCRATCH_ENGINE"
mkdir -p "$(dirname "$SCRATCH_ENGINE")"
# See build-engine.sh for why this detection is needed (e.g. a VirtualBox
# vboxsf shared folder refusing to create symlinks or preserve hard-link
# relationships) - applied here too since build/ is under the same
# bind-mounted repo checkout.
CP_FLAGS=(-a)
mkdir -p "$(dirname "$SCRATCH_ENGINE")/.symlink-test-dir"
if ! ln -s test-target "$(dirname "$SCRATCH_ENGINE")/.symlink-test-dir/test" 2>/dev/null; then
echo "(destination filesystem doesn't support symlinks - copying real file content instead)"
CP_FLAGS=(-a --dereference --no-preserve=links)
fi
rm -rf "$(dirname "$SCRATCH_ENGINE")/.symlink-test-dir"
cp "${CP_FLAGS[@]}" "$REPO_ROOT/engine" "$SCRATCH_ENGINE"
cd "$SCRATCH_ENGINE"
# If engine/ has ever been built in-source directly (`make dist`/`make
# engine` - see build-engine.sh), that leftover CMakeCache.txt etc. just
# got copied along verbatim - and it still points at the real engine/
# path, not this scratch copy, which CMake refuses to configure against
# ("directory is different than the directory where CMakeCache.txt was
# created"). Same artifact list as .gitignore's "Engine build artifacts"
# section.
rm -rf build_ext CMakeCache.txt CMakeFiles cmake_install.cmake Makefile \
systemshock src/Libraries/CMakeFiles
echo "== Wiring up prebuilt SDL2/SDL2_mixer/GLEW/fluidsynth-lite (Windows/MinGW) from the image =="
rm -rf build_ext
mkdir -p build_ext
cp "${CP_FLAGS[@]}" /opt/prebuilt/win/sdl2 build_ext/built_sdl
cp "${CP_FLAGS[@]}" /opt/prebuilt/win/sdl2_mixer build_ext/built_sdl_mixer
cp "${CP_FLAGS[@]}" /opt/prebuilt/win/glew build_ext/built_glew
# engine/CMakeLists.txt's BUNDLED FluidSynth mode hardcodes
# build_ext/fluidsynth-lite/src as the library search path (matching the
# desktop Linux in-source build's own output layout) - same lib/ -> src/
# remapping build-image/prepare-android-project.sh does for the Quest
# build's own prebuilt fluidsynth-lite.
mkdir -p build_ext/fluidsynth-lite/src build_ext/fluidsynth-lite/include
cp "${CP_FLAGS[@]}" /opt/prebuilt/win/fluidsynth-lite/lib/. build_ext/fluidsynth-lite/src/
cp "${CP_FLAGS[@]}" /opt/prebuilt/win/fluidsynth-lite/include/. build_ext/fluidsynth-lite/include/
echo "== Configuring (CMake, MinGW cross-compile, BUNDLED SDL2/SDL2_mixer/FluidSynth) =="
cmake -DCMAKE_TOOLCHAIN_FILE="$MINGW_TOOLCHAIN_FILE" \
-DENABLE_SDL2=BUNDLED -DENABLE_SOUND=BUNDLED -DENABLE_FLUIDSYNTH=BUNDLED .
echo "== Compiling =="
make -j"$(nproc)" systemshock
echo "== Assembling engine/.build-output-win =="
rm -rf "$OUT_DIR"
mkdir -p "$OUT_DIR"
cp systemshock.exe "$OUT_DIR/"
# Only SDL2.dll/SDL2_mixer.dll themselves - not SDL2_mixer's own bundled
# codec DLLs (libvorbis, libmodplug, libopus, ...), matching what
# engine/'s own upstream Windows build script (build_win64.sh) ships:
# Shockolate only ever calls Mix_LoadWAV_RW/Mix_HookMusic (same as the
# Quest build - see build-image/Dockerfile's Android layer comment),
# never loading the OGG/MOD/MP3 game data those codecs would be for.
cp build_ext/built_sdl/bin/SDL2.dll build_ext/built_sdl_mixer/bin/SDL2_mixer.dll "$OUT_DIR/"
cp build_ext/built_glew/lib/glew32.dll "$OUT_DIR/"
cp build_ext/fluidsynth-lite/src/*.dll "$OUT_DIR/"
# libgcc/libstdc++ are statically linked (see the Dockerfile's
# MINGW_TOOLCHAIN_FILE), but fluidsynth-lite/SDL2 still pull in the
# MinGW pthread emulation dynamically.
cp /usr/x86_64-w64-mingw32/lib/libwinpthread-1.dll "$OUT_DIR/"
cp /opt/prebuilt/soundfont/default.sf2 "$OUT_DIR/soundfont.sf2"
echo "== Done =="
echo "Binary: $OUT_DIR/systemshock.exe"
echo "DLLs: $OUT_DIR/*.dll"
echo "Soundfont: $OUT_DIR/soundfont.sf2"
+23 -4
View File
@@ -22,9 +22,28 @@ cd "$ENGINE_DIR"
echo "== Wiring up prebuilt SDL2/SDL2_mixer/fluidsynth-lite from the image =="
rm -rf build_ext
mkdir -p build_ext
cp -a /opt/prebuilt/built_sdl build_ext/
cp -a /opt/prebuilt/built_sdl_mixer build_ext/
cp -a /opt/prebuilt/fluidsynth-lite build_ext/
# Some filesystems the repo might be checked out on (e.g. a VirtualBox
# vboxsf shared folder) refuse to create symlinks at all ("Operation not
# permitted"), which plain `cp -a` needs for SDL2/SDL2_mixer's
# libFoo.so -> libFoo.so.N -> libFoo.so.N.M dev-symlink chain. Detect
# that up front, once, and copy real file content instead of recreating
# links if so, rather than failing partway through. `-a` implies
# --preserve=all (which includes hard-link relationships between files,
# not just symlinks - SDL2's install hard-links the two most-specific
# version files together), so --no-preserve=links is needed alongside
# --dereference to avoid that too.
CP_FLAGS=(-a)
if ! ln -s test-target build_ext/.symlink-test 2>/dev/null; then
echo "(destination filesystem doesn't support symlinks - copying real file content instead)"
CP_FLAGS=(-a --dereference --no-preserve=links)
else
rm -f build_ext/.symlink-test
fi
cp "${CP_FLAGS[@]}" /opt/prebuilt/built_sdl build_ext/
cp "${CP_FLAGS[@]}" /opt/prebuilt/built_sdl_mixer build_ext/
cp "${CP_FLAGS[@]}" /opt/prebuilt/fluidsynth-lite build_ext/
echo "== Configuring (CMake, BUNDLED SDL2/SDL2_mixer/FluidSynth) =="
rm -f CMakeCache.txt
@@ -38,7 +57,7 @@ rm -rf "$OUT_DIR"
mkdir -p "$OUT_DIR/lib"
cp systemshock "$OUT_DIR/"
find build_ext/built_sdl/lib build_ext/built_sdl_mixer/lib build_ext/fluidsynth-lite/src \
-name '*.so*' -not -name '*.la' -exec cp -a {} "$OUT_DIR/lib/" \;
-name '*.so*' -not -name '*.la' -exec cp "${CP_FLAGS[@]}" {} "$OUT_DIR/lib/" \;
cp /opt/prebuilt/soundfont/default.sf2 "$OUT_DIR/soundfont.sf2"
echo "== Done =="
+13
View File
@@ -11,4 +11,17 @@ USER_GID="${HOST_GID:-1000}"
groupadd -g "$USER_GID" builder 2>/dev/null || true
useradd -u "$USER_UID" -g "$USER_GID" -m -s /bin/bash builder 2>/dev/null || true
# /workspace (the bind-mounted repo) may actually be owned by a group the
# host user only has via *supplementary* membership rather than their
# primary GID above - e.g. a VirtualBox vboxsf shared folder, which shows
# up as root:vboxsf on the host and would otherwise be inaccessible to
# `builder` here (falls through to "other", which vboxsf's default mode
# leaves with no permissions at all). Join whatever group actually owns
# /workspace too, if it differs.
WORKSPACE_GID="$(stat -c %g /workspace 2>/dev/null || true)"
if [ -n "$WORKSPACE_GID" ] && [ "$WORKSPACE_GID" != "$USER_GID" ]; then
groupadd -g "$WORKSPACE_GID" workspace 2>/dev/null || true
usermod -aG "$WORKSPACE_GID" builder 2>/dev/null || true
fi
exec gosu builder "$@"
+3 -1
View File
@@ -81,7 +81,7 @@ fi
echo "prebuiltDir=$PREBUILT_DIR_PROP"
} > "$ANDROID_DIR/engine.properties"
echo "== Staging bundled assets (shaders, soundfont, get-assets text) =="
echo "== Staging bundled assets (shaders, soundfont, get-assets text, default config) =="
ASSETS_DIR="$ANDROID_DIR/app/src/main/assets"
rm -rf "$ASSETS_DIR"
mkdir -p "$ASSETS_DIR/shaders" "$ASSETS_DIR/res"
@@ -91,6 +91,8 @@ mkdir -p "$ASSETS_DIR/shaders" "$ASSETS_DIR/res"
cp -a "$ANDROID_DIR/gles-shaders/." "$ASSETS_DIR/shaders/"
cp "/opt/prebuilt/soundfont/default.sf2" "$ASSETS_DIR/res/soundfont.sf2"
cp "$REPO_ROOT/res/assets/GET_ASSETS_QUEST.txt" "$ASSETS_DIR/GET_ASSETS_QUEST.txt"
cp "$REPO_ROOT/res/questshock.config.default.json" "$ASSETS_DIR/questshock.config.default.json"
cp -a "$REPO_ROOT/res/controller_profiles" "$ASSETS_DIR/controller_profiles"
echo "== Staging prebuilt Android SDL2/SDL2_mixer/fluidsynth-lite/openxr .so's into jniLibs =="
# gl4es is deliberately absent here - it's no longer prebuilt, it's compiled
+64 -6
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
{
"name": "Generic OpenXR Controller",
"interaction_profile": "/interaction_profiles/khr/simple_controller",
"image": "generic.png",
"controls": [
{"id": "left_trigger", "label": "Select (Left)", "anchor_x": 0.30, "anchor_y": 0.60},
{"id": "right_trigger", "label": "Select (Right)", "anchor_x": 0.30, "anchor_y": 0.60}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+19
View File
@@ -0,0 +1,19 @@
{
"name": "Meta Quest Touch",
"interaction_profile": "/interaction_profiles/oculus/touch_controller",
"image": "touch.png",
"controls": [
{"id": "left_trigger", "label": "Trigger", "anchor_x": 0.30, "anchor_y": 0.45},
{"id": "right_trigger", "label": "Trigger", "anchor_x": 0.72, "anchor_y": 0.45},
{"id": "left_squeeze", "label": "Grip", "anchor_x": 0.12, "anchor_y": 0.55},
{"id": "right_squeeze", "label": "Grip", "anchor_x": 0.58, "anchor_y": 0.55},
{"id": "left_x", "label": "X", "anchor_x": 0.14, "anchor_y": 0.33},
{"id": "left_y", "label": "Y", "anchor_x": 0.24, "anchor_y": 0.35},
{"id": "right_a", "label": "A", "anchor_x": 0.68, "anchor_y": 0.35},
{"id": "right_b", "label": "B", "anchor_x": 0.75, "anchor_y": 0.33},
{"id": "left_thumbstick", "label": "Thumbstick", "anchor_x": 0.20, "anchor_y": 0.28},
{"id": "right_thumbstick", "label": "Thumbstick", "anchor_x": 0.62, "anchor_y": 0.30},
{"id": "left_thumbstick_click", "label": "Thumbstick Click", "anchor_x": 0.20, "anchor_y": 0.28},
{"id": "right_thumbstick_click", "label": "Thumbstick Click", "anchor_x": 0.62, "anchor_y": 0.30}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

+15
View File
@@ -0,0 +1,15 @@
{
"name": "HTC Vive Wand",
"interaction_profile": "/interaction_profiles/htc/vive_controller",
"image": "vive.png",
"controls": [
{"id": "left_trigger", "label": "Trigger", "anchor_x": 0.13, "anchor_y": 0.62},
{"id": "right_trigger", "label": "Trigger", "anchor_x": 0.78, "anchor_y": 0.62},
{"id": "left_squeeze", "label": "Grip", "anchor_x": 0.08, "anchor_y": 0.68},
{"id": "right_squeeze", "label": "Grip", "anchor_x": 0.85, "anchor_y": 0.68},
{"id": "left_thumbstick", "label": "Trackpad", "anchor_x": 0.20, "anchor_y": 0.42},
{"id": "right_thumbstick", "label": "Trackpad", "anchor_x": 0.72, "anchor_y": 0.42},
{"id": "left_thumbstick_click", "label": "Trackpad Click", "anchor_x": 0.20, "anchor_y": 0.42},
{"id": "right_thumbstick_click", "label": "Trackpad Click", "anchor_x": 0.72, "anchor_y": 0.42}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

+17
View File
@@ -0,0 +1,17 @@
{
"video_options": {
"renderer": "opengl",
"resolution": "1024x768",
"initial_screen_width_m": 1.600000,
"initial_screen_distance_m": 2.000000,
"game_screen_width_m": 3.400000,
"game_screen_distance_m": 2.000000,
"screen_width_offset_m": 1.000000,
"screen_width_step_m": 0.600000,
"screen_distance_offset_m": 1.000000,
"screen_distance_step_m": 0.500000
},
"general": {
"language": "english"
}
}
+4
View File
@@ -0,0 +1,4 @@
@echo off
cd /d "%~dp0"
systemshock.exe %*
exit /b %errorlevel%
Binary file not shown.

Before

Width:  |  Height:  |  Size: 381 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 641 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 575 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 707 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 435 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB