Compare commits
12 Commits
360a1f7e3d
..
v0.5.5
| Author | SHA1 | Date | |
|---|---|---|---|
| 790b4ffd21 | |||
| 135e474a14 | |||
| 295f4869c1 | |||
| f0a83df667 | |||
| 9586c58a67 | |||
| 5d7564c3b5 | |||
| 70c43240bc | |||
| d8324d409e | |||
| c24b7f15cb | |||
| e1e6a006d4 | |||
| 5fe4cdf7f8 | |||
| 369555efc7 |
@@ -69,7 +69,25 @@ jobs:
|
||||
dist/shockolate-*-linux-*.tar.gz
|
||||
dist/shockolate-*-windows-*.zip
|
||||
|
||||
# The release key is stored base64-encoded in ANDROID_KEYSTORE_BASE64
|
||||
# (see README section 4.3). Without it the APK is a debug build.
|
||||
- name: Prepare release signing
|
||||
env:
|
||||
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "$KEYSTORE_BASE64" ]; then
|
||||
echo "No signing key: the APK will be a debug build"
|
||||
exit 0
|
||||
fi
|
||||
echo "$KEYSTORE_BASE64" | base64 -d > "${RUNNER_TEMP:-/tmp}/questshock-release.jks"
|
||||
echo "QUESTSHOCK_KEYSTORE=${RUNNER_TEMP:-/tmp}/questshock-release.jks" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build Quest APK
|
||||
env:
|
||||
QUESTSHOCK_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
QUESTSHOCK_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
QUESTSHOCK_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
# QUESTSHOCK_BUILD_IMAGE is already set (see Preflight above), so
|
||||
# this runs build-apk.sh directly instead of trying to docker-run
|
||||
# the image again - same reasoning as `make package` above.
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
name: release
|
||||
|
||||
# GitHub's build. The Gitea build is .gitea/workflows/build.yml.
|
||||
#
|
||||
# Pushing a tag vX.Y.Z builds the Linux and Windows packages and the Quest APK
|
||||
# inside the build-image and attaches them to a GitHub release. A manual run
|
||||
# builds the same files and keeps them as a workflow artifact without
|
||||
# releasing them.
|
||||
#
|
||||
# The build-image (build-image/Dockerfile) is published to GHCR, tagged with a
|
||||
# hash of the files that define it, and only built when that tag does not
|
||||
# exist yet.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
image:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
outputs:
|
||||
name: ${{ steps.name.outputs.name }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Name the image after the files that define it
|
||||
id: name
|
||||
run: |
|
||||
set -euo pipefail
|
||||
hash="$(cat build-image/Dockerfile build-image/docker-entrypoint.sh build-image/build-engine.sh | sha256sum | cut -c1-16)"
|
||||
echo "name=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/questshock-builder:${hash}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build and push the image if it does not exist yet
|
||||
env:
|
||||
IMAGE: ${{ steps.name.outputs.name }}
|
||||
REGISTRY_USER: ${{ github.actor }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "$REGISTRY_PASSWORD" | docker login ghcr.io -u "$REGISTRY_USER" --password-stdin
|
||||
if docker manifest inspect "$IMAGE" >/dev/null 2>&1; then
|
||||
echo "$IMAGE already exists"
|
||||
exit 0
|
||||
fi
|
||||
# The image is large and the runner's disk is small.
|
||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
|
||||
# The label links the package to this repository, so the build job
|
||||
# can pull it with the workflow's token.
|
||||
docker build -f build-image/Dockerfile \
|
||||
--label "org.opencontainers.image.source=https://github.com/${GITHUB_REPOSITORY}" \
|
||||
-t "$IMAGE" .
|
||||
docker push "$IMAGE"
|
||||
|
||||
build:
|
||||
needs: image
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
# The job runs inside the build-image, so `make` compiles directly instead
|
||||
# of starting the image itself (QUESTSHOCK_BUILD_IMAGE is set in it).
|
||||
container:
|
||||
image: ${{ needs.image.outputs.name }}
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# The version comes from `git describe --tags`.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Build the Linux and Windows packages
|
||||
run: make package
|
||||
|
||||
# The release key is stored base64-encoded in ANDROID_KEYSTORE_BASE64; see
|
||||
# README section 4.3. A tag build must be signed with it.
|
||||
- name: Prepare release signing
|
||||
env:
|
||||
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "$KEYSTORE_BASE64" ]; then
|
||||
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
|
||||
echo "::error::ANDROID_KEYSTORE_BASE64 is not set: a release APK must be signed"
|
||||
exit 1
|
||||
fi
|
||||
echo "No signing key: the APK will be a debug build"
|
||||
exit 0
|
||||
fi
|
||||
echo "$KEYSTORE_BASE64" | base64 -d > "${RUNNER_TEMP:-/tmp}/questshock-release.jks"
|
||||
echo "QUESTSHOCK_KEYSTORE=${RUNNER_TEMP:-/tmp}/questshock-release.jks" >> "$GITHUB_ENV"
|
||||
|
||||
# Needs network access for Gradle's own dependency resolution.
|
||||
- name: Build the Quest APK
|
||||
env:
|
||||
QUESTSHOCK_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
QUESTSHOCK_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
QUESTSHOCK_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
run: make apk
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: questshock
|
||||
path: |
|
||||
dist/shockolate-*-linux-*.tar.gz
|
||||
dist/shockolate-*-windows-*.zip
|
||||
dist/questshock-*-android-*.apk
|
||||
|
||||
release:
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: questshock
|
||||
path: dist
|
||||
|
||||
- name: Create the release, or update its files if it exists
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
|
||||
gh release upload "$GITHUB_REF_NAME" dist/* --clobber
|
||||
else
|
||||
gh release create "$GITHUB_REF_NAME" dist/* \
|
||||
--title "$GITHUB_REF_NAME" \
|
||||
--generate-notes \
|
||||
--notes "Sideload the APK onto a Meta Quest (see README section 5.1). The game data is not included: you need your own copy of System Shock: Enhanced Edition."
|
||||
fi
|
||||
@@ -38,3 +38,7 @@
|
||||
|
||||
# Local registry credentials (see registry.env.example)
|
||||
/registry.env
|
||||
|
||||
# Signing keys
|
||||
*.jks
|
||||
*.keystore
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
# 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 13). 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), `splash.png` (startup splash), `logo.png` (1024x1024 logo, master of the launcher icons in `android/app/src/main/res/mipmap-*/`), `logo_small.png` (the README header), `screenshots/` (headset captures and videos named `questshock_v<version>_<nnn>.<ext>`, numbered per version), `assets/` (where the user's own game data goes, git-ignored), `run.sh`/`run.bat`. |
|
||||
| `.gitea/workflows/build.yml`, `.github/workflows/release.yml` | CI. Gitea is the primary repository and builds on every push; GitHub is a mirror whose workflow builds and publishes a release when a `vX.Y.Z` tag is pushed. Each platform has its own file. |
|
||||
| `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. |
|
||||
| `qs_splash.c/.h` | Startup splash: `qs_splash_show()` fades the image (`res/splash.png`, decoded by Java and passed in through `nativeSetSplashImage`) in and out on the game screen. Called from engine patch 27. |
|
||||
| `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.
|
||||
- `gScreenWide`/`gScreenHigh` are only set by the engine's
|
||||
`ChangeScreenSize()`, so they are 0 until its first mode change. Code that
|
||||
runs during startup (such as `qs_splash_show()`) takes the screen size from
|
||||
`xr_get_game_swapchain_size()` instead.
|
||||
|
||||
### 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 13).
|
||||
|
||||
### 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/splash.png` stays in the APK assets and is not extracted to the device;
|
||||
`QuestShockActivity` decodes it and hands the pixels to `qs_splash.c`.
|
||||
|
||||
`res/controller_profiles/`, `res/splash.png` and the default config are staged
|
||||
into APK assets by `build-image/prepare-android-project.sh`; every staged file
|
||||
or directory must also be declared as `inputs.file(...)`/`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. Signing
|
||||
|
||||
Distributed APKs are release builds signed with one fixed key, so users can
|
||||
update in place. `build-image/build-apk.sh` reads the key from
|
||||
`QUESTSHOCK_KEYSTORE`, `QUESTSHOCK_KEYSTORE_PASSWORD`, `QUESTSHOCK_KEY_ALIAS`
|
||||
and `QUESTSHOCK_KEY_PASSWORD`; `android/app/build.gradle` applies it to the
|
||||
`release` build type only when `QUESTSHOCK_KEYSTORE` is set. Both CI workflows
|
||||
decode it from the `ANDROID_KEYSTORE_BASE64` secret. Without a key the build is
|
||||
a debug APK (`...-debug.apk`), which is only for testing. Never commit a
|
||||
keystore or a password, print them, or put them in a workflow file (README
|
||||
section 4.3 lists the secrets).
|
||||
|
||||
## 12. 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(...)`.
|
||||
|
||||
## 13. 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`).
|
||||
|
||||
## 14. 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`.
|
||||
@@ -3,15 +3,17 @@
|
||||
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
|
||||
- **Location**: `/sdcard/QuestShock/questshock.config.json` - the same
|
||||
folder you already copy `res/data`/`res/sound` into (see README.md
|
||||
section 5.1).
|
||||
- **Format**: plain JSON, grouped by concern. Each top-level key is a
|
||||
*group*; each key inside a group is one *option*.
|
||||
- **Loading**: read automatically at app startup, before the splash screen,
|
||||
if the file exists. If it doesn't exist, nothing changes - the engine
|
||||
behaves exactly as it did before this file existed (driven by `prefs.txt`
|
||||
as usual).
|
||||
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
|
||||
@@ -47,7 +49,15 @@ startup, in addition to (and independent of) the engine's own `prefs.txt`.
|
||||
{
|
||||
"video_options": {
|
||||
"renderer": "opengl",
|
||||
"resolution": "1024x768"
|
||||
"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"
|
||||
@@ -56,6 +66,21 @@ startup, in addition to (and independent of) the engine's own `prefs.txt`.
|
||||
"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"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -78,6 +103,56 @@ The game's internal rendering resolution.
|
||||
- **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`
|
||||
@@ -99,6 +174,9 @@ project's own menu/keyboard chrome text (title bar, buttons, etc.).
|
||||
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`)
|
||||
|
||||
@@ -117,13 +195,14 @@ to an automatic recenter instead (see **Reset Position** below), using
|
||||
your real head pose at that moment. A complete saved position is applied
|
||||
as-is, with no such recenter.
|
||||
|
||||
**Reset Position** (Main Menu tab) is a separate, live-only action, not a
|
||||
config value: it recenters using your *current* real head pose, so the
|
||||
game quad ends up directly ahead of wherever you're actually standing/
|
||||
facing right now - not a plain "go back to `(0, 0, 0)`" (which would only
|
||||
undo thumbstick movement, not any physical drift/rotation since the
|
||||
session started). It's unrelated to `questshock.config.json` - it never
|
||||
reads or writes this file.
|
||||
**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`
|
||||
|
||||
@@ -138,3 +217,242 @@ 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.
|
||||
|
||||
@@ -8,6 +8,9 @@ and is not relicensed by this project's MIT license.
|
||||
|
||||
Game assets are not included - see res/GET_ASSETS.txt.
|
||||
|
||||
System Shock is a trademark of its respective owners. This is an unofficial
|
||||
fan project and is not affiliated with, endorsed by or supported by them.
|
||||
|
||||
The Android build additionally bundles:
|
||||
|
||||
- GL4ES (https://github.com/ptitSeb/gl4es), vendored and patched (see
|
||||
|
||||
@@ -6,6 +6,56 @@ An open source project to play the classic 1994 System Shock on a VR
|
||||
headset, built on top of [Shockolate](https://github.com/Interrupt/systemshock),
|
||||
a cross-platform port of the original game.
|
||||
|
||||
The game is played on a virtual screen: the engine renders its usual flat 2D
|
||||
frame, and Questshock shows it as a large screen floating in front of you in
|
||||
VR. It is not a stereoscopic 3D conversion; you sit or stand in front of the
|
||||
screen the way you would in front of a monitor or a cinema screen. The
|
||||
screen's width and distance can be set separately for the menus/intro and for
|
||||
gameplay. Both controllers act as laser pointers for the game's mouse-driven
|
||||
interface and for a VR menu and on-screen keyboard, and every controller
|
||||
button, stick direction and game hotkey can be assigned to a game action.
|
||||
Questshock runs entirely on the headset (tested on a Meta Quest 2) as a
|
||||
sideloaded Android app, and needs your own copy of the game data.
|
||||
|
||||
> **Unofficial fan project.** Questshock is not affiliated with, endorsed by or
|
||||
> supported by the rights holders of System Shock. It contains no game data: you
|
||||
> need your own copy of System Shock: Enhanced Edition.
|
||||
|
||||
## Screenshots
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="res/screenshots/questshock_v0.5.3_001.jpg" alt="Exploring the station on the virtual screen"><br><sub>Exploring the station on the virtual screen</sub></td>
|
||||
<td width="50%"><img src="res/screenshots/questshock_v0.5.3_003.jpg" alt="Reading an email - the laser pointer clicks like a mouse"><br><sub>Reading an email - the laser pointer clicks like a mouse</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="res/screenshots/questshock_v0.5.3_005.jpg" alt="Cyberspace"><br><sub>Cyberspace</sub></td>
|
||||
<td width="50%"><img src="res/screenshots/questshock_v0.5.3_006.jpg" alt="The game screen, the menu and the keyboard float independently"><br><sub>The game screen, the menu and the keyboard float independently</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="res/screenshots/questshock_v0.5.3_007.jpg" alt="The VR menu"><br><sub>The VR menu</sub></td>
|
||||
<td width="50%"><img src="res/screenshots/questshock_v0.5.3_008.jpg" alt="Assigning controller inputs to game actions"><br><sub>Assigning controller inputs to game actions</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
[A short video](res/screenshots/questshock_v0.5.3_010.mp4) shows the game in use.
|
||||
|
||||
## Quick start
|
||||
|
||||
You need a Meta Quest (tested on a Quest 2) and your own copy of the game data.
|
||||
|
||||
1. Get **System Shock: Enhanced Edition** (for example from
|
||||
[gog.com](https://www.gog.com/)); see "3. Game assets" below.
|
||||
2. Download `questshock-<version>-android-arm64.apk` from the repository's
|
||||
Releases page and sideload it onto the headset with
|
||||
[SideQuest](https://sidequestvr.com/) or `adb install`.
|
||||
3. Launch it once. It creates `/sdcard/QuestShock/`. Copy your game's `res/data/`
|
||||
and `res/sound/` folders into `/sdcard/QuestShock/res/`.
|
||||
4. Launch it again. The left controller's menu button opens the menu; the
|
||||
default controls are in "5.1.1. Menu, controls and cheats" below.
|
||||
|
||||
Section "5.1. Installing and playing" has the details.
|
||||
|
||||
## 1. Design principles
|
||||
|
||||
- **Standalone-headset-only.** The game must run entirely on the headset
|
||||
@@ -31,6 +81,15 @@ 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, as is
|
||||
`res/splash.png`, the startup splash image. `res/logo.png` is the 1024x1024
|
||||
logo, the master of the launcher icon
|
||||
(`android/app/src/main/res/mipmap-*/ic_launcher.png` are scaled copies of it)
|
||||
and the image to use for store listings; `res/logo_small.png` is the copy
|
||||
shown at the top of this file.
|
||||
- `Makefile` - targets:
|
||||
- `all` (default) - alias for `dist`.
|
||||
- `build-image` - builds the Docker build-image (`./build-image.sh`).
|
||||
@@ -49,9 +108,11 @@ a cross-platform port of the original game.
|
||||
proprietary game assets (ships `res/GET_ASSETS.txt` in their place).
|
||||
See "4.1. Packaging a distributable build" below.
|
||||
- `apk` - builds `dist/questshock-<version>-android-arm64.apk` for
|
||||
Android-based OpenXR headsets. Unlike every other target, this needs
|
||||
network access at build time (Gradle/AGP's own dependency
|
||||
resolution). See "5. Android / Quest build" below.
|
||||
Android-based OpenXR headsets (a debug build, `...-arm64-debug.apk`,
|
||||
when there is no signing key - see "4.3. Releases and signing" below).
|
||||
Unlike every other target, this needs network access at build time
|
||||
(Gradle/AGP's own dependency resolution). See "5. Android / Quest
|
||||
build" below.
|
||||
- `android-studio` - stages `engine/`/`android/gl4es-src/` (scratch,
|
||||
patched copies) and the Android prebuilts with host-resolvable paths,
|
||||
so Android Studio can compile and deploy `android/` natively instead
|
||||
@@ -105,6 +166,10 @@ Enhanced Edition one and `extract_assets.sh` pulls them out of it too,
|
||||
entirely optionally - it still runs fine with just the Enhanced Edition
|
||||
installer.
|
||||
|
||||
The intro cutscene follows the selected language too, when the matching
|
||||
voice-over file (`svfrintr.res`/`svgeintr.res` under `res/data/`) is present;
|
||||
otherwise the English intro plays.
|
||||
|
||||
## 4. Desktop build
|
||||
|
||||
Builds and runs Questshock natively on the desktop (your dev machine, or
|
||||
@@ -191,6 +256,49 @@ package on every push, using the build-image as its container (so no
|
||||
extra setup is needed in CI beyond the image itself), and publishes the
|
||||
resulting tarball to dl.ladkau.de.
|
||||
|
||||
### 4.3. Releases and signing
|
||||
|
||||
The repository is developed on Gitea, and the GitHub repository is a mirror of
|
||||
it. `.github/workflows/release.yml` is GitHub's own workflow (the Gitea build
|
||||
is `.gitea/workflows/build.yml`):
|
||||
|
||||
- Pushing a tag `vX.Y.Z` builds the Linux and Windows packages and the Quest
|
||||
APK inside the build-image and attaches them to a GitHub release with the
|
||||
same name. A manual run ("Run workflow") builds the same files without
|
||||
releasing them and keeps them as a downloadable workflow artifact.
|
||||
- The build-image is pushed to GitHub's container registry (`ghcr.io`) when it
|
||||
does not exist there yet. Its tag is a hash of `build-image/Dockerfile`,
|
||||
`docker-entrypoint.sh` and `build-engine.sh`, so a change to any of them
|
||||
builds a new image (`build-image/VERSION` is not used). The first run is slow
|
||||
because it builds the image.
|
||||
- The APK is signed with the release key described below.
|
||||
|
||||
**Signing.** Android only updates an installed app from an APK signed with the
|
||||
same key, so every distributed APK is signed with one fixed key. `make apk`
|
||||
(`build-image/build-apk.sh`) builds and signs the release APK when
|
||||
`QUESTSHOCK_KEYSTORE` is set, together with `QUESTSHOCK_KEYSTORE_PASSWORD` and
|
||||
optionally `QUESTSHOCK_KEY_ALIAS` (default `questshock`) and
|
||||
`QUESTSHOCK_KEY_PASSWORD` (default: the keystore password). Without a keystore
|
||||
it builds a debug APK, `questshock-<version>-android-arm64-debug.apk`, signed
|
||||
with a per-machine debug key. That is what Android Studio's Run uses too; an
|
||||
installed build has to be uninstalled before one signed with another key can be
|
||||
installed.
|
||||
|
||||
Create the key once and keep the file and its password safe: without them,
|
||||
users have to uninstall the app to get an update.
|
||||
|
||||
```sh
|
||||
keytool -genkeypair -v -storetype PKCS12 -keystore questshock-release.jks \
|
||||
-alias questshock -keyalg RSA -keysize 4096 -validity 10000
|
||||
base64 -w0 questshock-release.jks # the value of ANDROID_KEYSTORE_BASE64
|
||||
```
|
||||
|
||||
Both CI systems read the key from these secrets: `ANDROID_KEYSTORE_BASE64`,
|
||||
`ANDROID_KEYSTORE_PASSWORD`, and optionally `ANDROID_KEY_ALIAS` and
|
||||
`ANDROID_KEY_PASSWORD`. Without `ANDROID_KEYSTORE_BASE64`, the Gitea build
|
||||
produces a debug APK and the GitHub build fails on a tag. `*.jks` and
|
||||
`*.keystore` are git-ignored.
|
||||
|
||||
## 5. Android / Quest build
|
||||
|
||||
**Prerequisites:**
|
||||
@@ -205,7 +313,8 @@ resulting tarball to dl.ladkau.de.
|
||||
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
|
||||
`make apk` builds `dist/questshock-<version>-android-arm64.apk` (or
|
||||
`...-arm64-debug.apk`, see "4.3. Releases and signing" above) - an
|
||||
immersive OpenXR app (see `android/app/src/main/cpp/xr_session.c`) that
|
||||
can be sideloaded onto any Android-based VR headset with OpenXR support
|
||||
(Meta Quest, Pico, etc. - see "1. Design principles" above), not just one
|
||||
@@ -218,18 +327,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
|
||||
@@ -275,20 +383,66 @@ debugging enabled.)
|
||||
1. Install the APK with [SideQuest](https://sidequestvr.com/) (or `adb
|
||||
install`).
|
||||
2. Launch it once. It'll ask for storage permission, then create
|
||||
`/sdcard/questshock/` and extract its own bundled files (shaders, a
|
||||
`/sdcard/QuestShock/` and extract its own bundled files (shaders, a
|
||||
default MIDI soundfont) there - `res/data/` and `res/sound/` are
|
||||
deliberately left missing, since that's the proprietary game data.
|
||||
3. With the headset connected to a PC, use SideQuest's file browser (or
|
||||
any MTP file manager) to copy your own `res/data/` and `res/sound/`
|
||||
(see `/sdcard/questshock/GET_ASSETS_QUEST.txt`, extracted in step 2,
|
||||
(see `/sdcard/QuestShock/GET_ASSETS_QUEST.txt`, extracted in step 2,
|
||||
for exactly what's needed and where it comes from) into
|
||||
`/sdcard/questshock/res/`.
|
||||
`/sdcard/QuestShock/res/`.
|
||||
4. Launch it again.
|
||||
|
||||
`/sdcard/questshock/` also holds an optional persistent settings file,
|
||||
`/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.
|
||||
options. Every launch also refreshes `questshock.config.default.json` (the
|
||||
bundled defaults, used when there is no `questshock.config.json`) and
|
||||
`controller_profiles/` (controller illustrations for the Controller Options
|
||||
tab) there; your own saved config and any extra profiles you add are left
|
||||
alone.
|
||||
|
||||
#### 5.1.1. Menu, controls and cheats
|
||||
|
||||
At startup the QuestShock title (`res/splash.png`) fades in and out on the
|
||||
virtual screen for a few seconds, before the game's own intro screens.
|
||||
|
||||
The controller's menu button (left controller) opens and closes the menu. It
|
||||
can't be reassigned. Opening it pauses a running game; closing it resumes the
|
||||
game, unless it was already paused. Aim with either hand's ray and pull the
|
||||
trigger to press buttons. The menu has four tabs:
|
||||
|
||||
- **Main Menu** - Keyboard, Save/Load Configuration, Reset Position and the
|
||||
language.
|
||||
- **Video Options** - renderer, resolution, and the width and distance of the
|
||||
virtual screen for the initial screen (splash, intro, main menu) and the game
|
||||
screen.
|
||||
- **Controller Options** - shows the active controller with labelled inputs,
|
||||
and one button per action to assign it to a controller input (tap the
|
||||
button, then press the input). Assigning an input that another action
|
||||
already uses unbinds that other action. **Reset to Defaults** restores the
|
||||
default bindings.
|
||||
- **Cheats** - the engine's cheats as buttons (give all items and weapons,
|
||||
toggle physics, next and previous level) and four independent switches for
|
||||
unlimited health, energy, ammo and items. The switches last until the app
|
||||
restarts and are not saved. They only work while a game is running.
|
||||
|
||||
Default controls (all assignable, see [CONFIG.md](CONFIG.md)):
|
||||
|
||||
| Input | On foot | In cyberspace |
|
||||
|---|---|---|
|
||||
| Left stick | Move forward/back, strafe | Pitch up/down, bank left/right |
|
||||
| Right stick left/right | Turn | Roll |
|
||||
| Right stick up | Stand | Thrust |
|
||||
| Right stick down | Prone | - |
|
||||
| Left stick click | Jump | - |
|
||||
| Right stick click | Automap | - |
|
||||
| Right A / B | Click / double-click | Click / double-click |
|
||||
| Right trigger | Fire weapon | Fire weapon |
|
||||
| Left grip (hold) | Free view with the left stick | Free view with the left stick |
|
||||
|
||||
Every game hotkey (weapon select, MFD panels, save/load, ...) can also be
|
||||
assigned to a controller input; they are unbound by default.
|
||||
|
||||
### 5.2. Building natively in Android Studio
|
||||
|
||||
@@ -350,7 +504,7 @@ the same as any other Android Studio project:
|
||||
Android Studio builds, installs, and launches the app on the headset
|
||||
directly. No manual `adb install` or SideQuest step needed for this
|
||||
loop.
|
||||
4. `/sdcard/questshock/` (game assets, extracted shaders/soundfont)
|
||||
4. `/sdcard/QuestShock/` (game assets, extracted shaders/soundfont)
|
||||
persists across reinstalls from Android Studio, so this loop doesn't
|
||||
require re-copying `res/data/`/`res/sound/` on every iteration - only
|
||||
a full uninstall or wiping that directory clears it.
|
||||
@@ -533,6 +687,26 @@ each one does:
|
||||
`qs_config_load_startup()` in `Shock.c`'s `main()`, right after
|
||||
`LoadPrefs()`, and one check in `sdl_events.c`'s `pump_events()` to drain
|
||||
the custom SDL events the menu's Save/Load Configuration buttons enqueue.
|
||||
- **`22-android-screen-profile.patch`** - `mainloop.c` reports every
|
||||
loop-mode change to `qs_screen_profile_notify_loop_mode()`, which picks
|
||||
between the initial-screen and game-screen virtual-screen presets (see
|
||||
[CONFIG.md](CONFIG.md)) and tells the menu whether a game is running.
|
||||
- **`23-android-intro-language.patch`** - `cutsloop.c` opens the intro
|
||||
cutscene file for the current language (`svgaintr.res`/`svfrintr.res`/
|
||||
`svgeintr.res`), falling back to English when it isn't installed.
|
||||
- **`24-android-cyberspace-query.patch`** - adds `qs_in_cyberspace()` to
|
||||
`mouselook.c`, which `xr_input.c` uses to switch between the on-foot and
|
||||
cyberspace controller actions.
|
||||
- **`25-android-cyberspace-analog-scale.patch`** - `movekeys.c` scales the
|
||||
cyberspace pitch and bank strength by `qs_cyber_pitch_scale`/
|
||||
`qs_cyber_bank_scale`, which `xr_input.c` sets from the thumbstick
|
||||
deflection each frame (1.0 for keys and buttons).
|
||||
- **`26-android-cheat-refill.patch`** - adds `qs_cheat_refill()` to
|
||||
`hkeyfunc.c`, which tops up health, energy, ammo and items (grenades and
|
||||
drugs the player already holds) for the Cheats tab's unlimited switches.
|
||||
- **`27-android-startup-splash.patch`** - `setup.c`'s `splash_draw()` calls
|
||||
`qs_splash_show()` first, which fades the QuestShock title in and out on the
|
||||
game screen before the engine's own splash screens.
|
||||
|
||||
`android/gl4es-patches/`:
|
||||
|
||||
@@ -880,6 +1054,10 @@ under the **GNU GPLv3** (see `engine/LICENSE`) - it is included unchanged
|
||||
and is *not* relicensed by this project's MIT license. Any build or
|
||||
distribution of the compiled engine must comply with the GPLv3.
|
||||
|
||||
System Shock is a trademark of its respective owners. Questshock is an
|
||||
unofficial fan project and is not affiliated with, endorsed by or supported by
|
||||
them.
|
||||
|
||||
The game assets extracted into `res/assets/ss_ee/` are proprietary,
|
||||
copyrighted game data owned by their respective rightsholders - they are
|
||||
never committed to this repository (see `.gitignore`) and must be
|
||||
|
||||
@@ -104,10 +104,15 @@ 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")
|
||||
inputs.file("${repoRoot}/res/splash.png")
|
||||
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
|
||||
@@ -134,7 +139,7 @@ android {
|
||||
// 29, not compileSdk's 34, is deliberate - see build-image/Dockerfile's
|
||||
// comment on ANDROID_PLATFORM_VERSION: paired with
|
||||
// requestLegacyExternalStorage in the manifest, this keeps
|
||||
// /sdcard/questshock/ a plain, unrestricted shared folder instead of
|
||||
// /sdcard/QuestShock/ a plain, unrestricted shared folder instead of
|
||||
// Android 11+ scoped storage, on the Quest 2/3/3S/Pro headsets this
|
||||
// targets - not just whatever's the current API level today.
|
||||
minSdkVersion 24
|
||||
@@ -195,7 +200,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;${projectDir}/src/main/cpp/xr_mouse.c;${projectDir}/src/main/cpp/qs_config.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;${projectDir}/src/main/cpp/qs_splash.c"
|
||||
abiFilters 'arm64-v8a'
|
||||
}
|
||||
}
|
||||
@@ -207,9 +212,28 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// The release key comes from the environment (build-image/build-apk.sh,
|
||||
// CI secrets). Without it there is no release signing, and the release
|
||||
// build type is left unsigned.
|
||||
def releaseKeystore = System.getenv("QUESTSHOCK_KEYSTORE")
|
||||
def releaseStorePassword = System.getenv("QUESTSHOCK_KEYSTORE_PASSWORD")
|
||||
signingConfigs {
|
||||
release {
|
||||
if (releaseKeystore) {
|
||||
storeFile file(releaseKeystore)
|
||||
storePassword releaseStorePassword
|
||||
keyAlias System.getenv("QUESTSHOCK_KEY_ALIAS") ?: "questshock"
|
||||
keyPassword System.getenv("QUESTSHOCK_KEY_PASSWORD") ?: releaseStorePassword
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
if (releaseKeystore) {
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,11 @@
|
||||
OpenXR session to actually start. -->
|
||||
<meta-data android:name="com.oculus.vr.focusaware" android:value="true" />
|
||||
|
||||
<!-- Meta-specific: the headsets this app is built for. Other OpenXR
|
||||
runtimes ignore it. -->
|
||||
<meta-data android:name="com.oculus.supportedDevices"
|
||||
android:value="quest2|questpro|quest3|quest3s" />
|
||||
|
||||
<activity android:name="de.ladkau.questshock.QuestShockActivity"
|
||||
android:label="@string/app_name"
|
||||
android:alwaysRetainTaskState="true"
|
||||
@@ -51,13 +56,10 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<!-- The vendor-neutral, Khronos-defined way to declare an
|
||||
immersive OpenXR entry point (vs. e.g. Meta's own
|
||||
com.oculus.intent.category.VR) - see this project's
|
||||
"favor OpenXR" rule in the README. If Horizon OS still
|
||||
launches this as a flat panel on-device, the next thing
|
||||
to try is adding a com.oculus.supportedDevices meta-data
|
||||
listing the target Quest models - not confirmed
|
||||
necessary from static analysis alone. -->
|
||||
immersive OpenXR entry point, plus Meta's own VR
|
||||
category, which Horizon OS looks for to treat the app as
|
||||
a VR app. Other runtimes ignore the Meta one. -->
|
||||
<category android:name="com.oculus.intent.category.VR" />
|
||||
<category android:name="org.khronos.openxr.intent.category.IMMERSIVE_HMD" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
|
||||
@@ -28,6 +28,10 @@
|
||||
#include "tools.h"
|
||||
|
||||
#define QS_CONFIG_FILENAME "questshock.config.json"
|
||||
// Bundled with the app (QuestShockActivity.java extracts it fresh on every
|
||||
// launch) and read only at startup, only if QS_CONFIG_FILENAME doesn't
|
||||
// exist yet - never touched by Save/Load Configuration.
|
||||
#define QS_CONFIG_DEFAULT_FILENAME "questshock.config.default.json"
|
||||
|
||||
// ===========================================================================
|
||||
// Minimal JSON parser
|
||||
@@ -330,6 +334,27 @@ static void qs_json_take_number_field(QsJsonParser *p, float *out, bool *has) {
|
||||
typedef struct {
|
||||
char renderer[16]; // "" = not present in the file
|
||||
char resolution[16]; // "" = not present in the file
|
||||
// The virtual screen's physical width (meters) for each of the two
|
||||
// screen profiles - see qs_screen_profile_notify_loop_mode()'s own
|
||||
// comment. Like QsParsedPlayerPosition below (and unlike renderer/
|
||||
// resolution above), these are plain numbers, not string enums, and
|
||||
// need their own has_* bools since 0.0f is a valid value.
|
||||
float initial_screen_width_m, initial_screen_distance_m;
|
||||
float game_screen_width_m, game_screen_distance_m;
|
||||
bool has_initial_screen_width_m, has_initial_screen_distance_m;
|
||||
bool has_game_screen_width_m, has_game_screen_distance_m;
|
||||
// Each of the two options (width/distance) is offered as 5 preset
|
||||
// buttons in the Video Options tab, generated as offset + step*index
|
||||
// (index 0..4) rather than a fixed table - see qs_preset_value()'s
|
||||
// own comment. Shared by both "Initial Screen" and "Game Screen"
|
||||
// (both columns' preset buttons show the same 5 values per row) -
|
||||
// config-file-only (no UI control): the buttons are for adjusting
|
||||
// each profile's own current *value*, this offset/step pair is for
|
||||
// reshaping what those buttons themselves offer.
|
||||
float screen_width_offset_m, screen_width_step_m;
|
||||
float screen_distance_offset_m, screen_distance_step_m;
|
||||
bool has_screen_width_offset_m, has_screen_width_step_m;
|
||||
bool has_screen_distance_offset_m, has_screen_distance_step_m;
|
||||
} QsParsedVideoOptions;
|
||||
|
||||
typedef struct {
|
||||
@@ -344,6 +369,14 @@ typedef struct {
|
||||
bool has_x, has_z, has_yaw_degrees;
|
||||
} QsParsedPlayerPosition;
|
||||
|
||||
// One string slot per remappable action, indexed by
|
||||
// xr_input_action_index_from_name(). This file does not list the actions
|
||||
// itself; xr_input.c owns them. Sized above the current action count.
|
||||
#define QS_MAX_PLAYER_CONTROL_ACTIONS 96
|
||||
typedef struct {
|
||||
char bindings[QS_MAX_PLAYER_CONTROL_ACTIONS][24]; // "" = not present in the file
|
||||
} QsParsedPlayerControls;
|
||||
|
||||
typedef struct {
|
||||
QsParsedVideoOptions video_options;
|
||||
bool has_video_options;
|
||||
@@ -351,6 +384,8 @@ typedef struct {
|
||||
bool has_general;
|
||||
QsParsedPlayerPosition player_position;
|
||||
bool has_player_position;
|
||||
QsParsedPlayerControls player_controls;
|
||||
bool has_player_controls;
|
||||
} QsParsedConfig;
|
||||
|
||||
static void qs_parse_video_options_field(const char *key, QsJsonParser *p, void *ctx) {
|
||||
@@ -359,6 +394,26 @@ static void qs_parse_video_options_field(const char *key, QsJsonParser *p, void
|
||||
qs_json_take_string_field(p, out->renderer, sizeof(out->renderer));
|
||||
else if (strcmp(key, "resolution") == 0)
|
||||
qs_json_take_string_field(p, out->resolution, sizeof(out->resolution));
|
||||
else if (strcmp(key, "initial_screen_width_m") == 0)
|
||||
qs_json_take_number_field(p, &out->initial_screen_width_m, &out->has_initial_screen_width_m);
|
||||
else if (strcmp(key, "game_screen_width_m") == 0)
|
||||
qs_json_take_number_field(p, &out->game_screen_width_m, &out->has_game_screen_width_m);
|
||||
else if (strcmp(key, "initial_screen_distance_m") == 0)
|
||||
qs_json_take_number_field(p, &out->initial_screen_distance_m,
|
||||
&out->has_initial_screen_distance_m);
|
||||
else if (strcmp(key, "game_screen_distance_m") == 0)
|
||||
qs_json_take_number_field(p, &out->game_screen_distance_m,
|
||||
&out->has_game_screen_distance_m);
|
||||
else if (strcmp(key, "screen_width_offset_m") == 0)
|
||||
qs_json_take_number_field(p, &out->screen_width_offset_m, &out->has_screen_width_offset_m);
|
||||
else if (strcmp(key, "screen_width_step_m") == 0)
|
||||
qs_json_take_number_field(p, &out->screen_width_step_m, &out->has_screen_width_step_m);
|
||||
else if (strcmp(key, "screen_distance_offset_m") == 0)
|
||||
qs_json_take_number_field(p, &out->screen_distance_offset_m,
|
||||
&out->has_screen_distance_offset_m);
|
||||
else if (strcmp(key, "screen_distance_step_m") == 0)
|
||||
qs_json_take_number_field(p, &out->screen_distance_step_m,
|
||||
&out->has_screen_distance_step_m);
|
||||
else
|
||||
qs_json_skip_value(p);
|
||||
}
|
||||
@@ -383,6 +438,17 @@ static void qs_parse_player_position_field(const char *key, QsJsonParser *p, voi
|
||||
qs_json_skip_value(p);
|
||||
}
|
||||
|
||||
// Looks up `key` in xr_input.c's action-name list instead of a fixed strcmp
|
||||
// chain.
|
||||
static void qs_parse_player_controls_field(const char *key, QsJsonParser *p, void *ctx) {
|
||||
QsParsedPlayerControls *out = (QsParsedPlayerControls *)ctx;
|
||||
int index;
|
||||
if (xr_input_action_index_from_name(key, &index) && index < QS_MAX_PLAYER_CONTROL_ACTIONS)
|
||||
qs_json_take_string_field(p, out->bindings[index], sizeof(out->bindings[index]));
|
||||
else
|
||||
qs_json_skip_value(p);
|
||||
}
|
||||
|
||||
static void qs_parse_top_level_field(const char *key, QsJsonParser *p, void *ctx) {
|
||||
QsParsedConfig *out = (QsParsedConfig *)ctx;
|
||||
if (strcmp(key, "video_options") == 0 && !p->error && p->p < p->end && *p->p == '{')
|
||||
@@ -392,17 +458,20 @@ static void qs_parse_top_level_field(const char *key, QsJsonParser *p, void *ctx
|
||||
else if (strcmp(key, "player_position") == 0 && !p->error && p->p < p->end && *p->p == '{')
|
||||
out->has_player_position =
|
||||
qs_json_parse_object(p, qs_parse_player_position_field, &out->player_position);
|
||||
else if (strcmp(key, "player_controls") == 0 && !p->error && p->p < p->end && *p->p == '{')
|
||||
out->has_player_controls =
|
||||
qs_json_parse_object(p, qs_parse_player_controls_field, &out->player_controls);
|
||||
else
|
||||
qs_json_skip_value(p);
|
||||
}
|
||||
|
||||
// Reads and parses QS_CONFIG_FILENAME into *out. Returns false (with *out
|
||||
// zeroed) if the file doesn't exist, is empty, or fails to parse as valid
|
||||
// JSON - logging a WARN only for the last case, since a missing file is the
|
||||
// Reads and parses `filename` into *out. Returns false (with *out zeroed)
|
||||
// if the file doesn't exist, is empty, or fails to parse as valid JSON -
|
||||
// logging a WARN only for the last case, since a missing file is the
|
||||
// normal/expected state, not an error.
|
||||
static bool qs_config_read_file(QsParsedConfig *out) {
|
||||
static bool qs_config_read_file_named(const char *filename, QsParsedConfig *out) {
|
||||
memset(out, 0, sizeof(*out));
|
||||
FILE *f = fopen(QS_CONFIG_FILENAME, "rb");
|
||||
FILE *f = fopen(filename, "rb");
|
||||
if (!f)
|
||||
return false;
|
||||
fseek(f, 0, SEEK_END);
|
||||
@@ -425,13 +494,17 @@ static bool qs_config_read_file(QsParsedConfig *out) {
|
||||
bool ok = qs_json_parse_object(&parser, qs_parse_top_level_field, out) && !parser.error;
|
||||
free(buf);
|
||||
if (!ok) {
|
||||
WARN("%s: malformed JSON - ignoring file", QS_CONFIG_FILENAME);
|
||||
WARN("%s: malformed JSON - ignoring file", filename);
|
||||
memset(out, 0, sizeof(*out));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool qs_config_read_file(QsParsedConfig *out) {
|
||||
return qs_config_read_file_named(QS_CONFIG_FILENAME, out);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Video Options: renderer
|
||||
// ===========================================================================
|
||||
@@ -531,6 +604,149 @@ static const char *qs_current_resolution(void) {
|
||||
return "640x480"; // shouldn't happen - safe fallback for Save
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Video Options: screen profile (initial screen / game screen)
|
||||
// ===========================================================================
|
||||
|
||||
// Live-appliable: xr_session_set_game_screen_shape() (xr_session.c) is a
|
||||
// plain state setter with no XR calls, same category as
|
||||
// xr_session_set_player_transform() - safe to call live or at startup.
|
||||
#define QS_SCREEN_PROFILE_REQUIRES_RESTART false
|
||||
|
||||
// mainloop.h's own loop-mode #defines - declared locally rather than
|
||||
// #include "mainloop.h", which pulls in an `extern LGRegion *_current_view;`
|
||||
// this file can't resolve (LGRegion isn't visible via any header this file
|
||||
// already includes) - same "avoid a header with declarations this file has
|
||||
// no other reason to need" reasoning as screen_draw()/language_change()
|
||||
// above. These two values must stay in sync with mainloop.h's own
|
||||
// SETUP_LOOP/CUTSCENE_LOOP.
|
||||
#define QS_SETUP_LOOP 4
|
||||
#define QS_CUTSCENE_LOOP 6
|
||||
|
||||
// Each of the two options (width/distance) offers 5 preset buttons in
|
||||
// the Video Options tab, generated as offset+step*index (index 0..4)
|
||||
// rather than a fixed table - see qs_preset_value() below. Shared by
|
||||
// both "Initial Screen" and "Game Screen" (both columns' preset buttons
|
||||
// show the same 5 values per row), config-file-only (no UI control):
|
||||
// unlike the four *_screen_width_m-style current-value statics below
|
||||
// (button-driven, live-appliable, one pair per profile), these only
|
||||
// reshape what the buttons themselves offer.
|
||||
#define QS_NUM_SCREEN_PRESETS 5
|
||||
static float s_screen_width_offset_m = 1.0f;
|
||||
static float s_screen_width_step_m = 0.6f;
|
||||
static float s_screen_distance_offset_m = 1.0f;
|
||||
static float s_screen_distance_step_m = 0.5f;
|
||||
|
||||
// Generates preset index `index`'s value (index 0..QS_NUM_SCREEN_PRESETS-1)
|
||||
// for a given offset/step pair - e.g. offset 1.0/step 0.6 generates
|
||||
// 1.0, 1.6, 2.2, 2.8, 3.4 (this feature's original width presets).
|
||||
static float qs_preset_value(float offset, float step, int index) {
|
||||
return offset + step * (float)index;
|
||||
}
|
||||
|
||||
// Inverse of qs_preset_value() - returns -1 (a legitimate result here,
|
||||
// unlike kResolutions'-style "shouldn't happen" fallbacks - a hand-edited
|
||||
// config file can set any value in between presets) if the current value
|
||||
// doesn't exactly match one of the 5 presets this offset/step pair
|
||||
// generates.
|
||||
static int qs_preset_index(float offset, float step, float value) {
|
||||
for (int i = 0; i < QS_NUM_SCREEN_PRESETS; i++)
|
||||
if (qs_preset_value(offset, step, i) == value)
|
||||
return i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
// 1.6m/2.0m (qs_preset_value() index 1/2 of the shared offset/step
|
||||
// defaults above) for both profiles - the game screen's own original,
|
||||
// pre-this-feature hardcoded shape (QUAD_DISTANCE_METERS in xr_session.c),
|
||||
// so a config file/build that never touches these renders identically to
|
||||
// before until the player opts in.
|
||||
static float s_initial_screen_width_m = 1.6f;
|
||||
static float s_initial_screen_distance_m = 2.0f;
|
||||
static float s_game_screen_width_m = 1.6f;
|
||||
static float s_game_screen_distance_m = 2.0f;
|
||||
// Which profile xr_session.c's game-screen shape was last set to - so
|
||||
// qs_screen_profile_notify_loop_mode() can skip re-applying anything on a
|
||||
// same-profile transition (e.g. game -> automap -> game), and so a preset
|
||||
// button tap while the *other* profile is active knows to just update the
|
||||
// stored value without touching the live shape (see
|
||||
// qs_config_handle_event()'s own screen-profile branches).
|
||||
static bool s_screen_profile_is_game = false;
|
||||
|
||||
static float qs_clamp_screen_width(float width_m) {
|
||||
if (width_m < 0.2f)
|
||||
return 0.2f;
|
||||
if (width_m > 10.0f)
|
||||
return 10.0f;
|
||||
return width_m;
|
||||
}
|
||||
|
||||
static float qs_clamp_screen_distance(float distance_m) {
|
||||
if (distance_m < 0.5f)
|
||||
return 0.5f;
|
||||
if (distance_m > 10.0f)
|
||||
return 10.0f;
|
||||
return distance_m;
|
||||
}
|
||||
|
||||
static void qs_screen_profile_reapply(void) {
|
||||
if (s_screen_profile_is_game)
|
||||
xr_session_set_game_screen_shape(s_game_screen_width_m, 0.0f, s_game_screen_distance_m);
|
||||
else
|
||||
xr_session_set_game_screen_shape(s_initial_screen_width_m, 0.0f,
|
||||
s_initial_screen_distance_m);
|
||||
}
|
||||
|
||||
// Called directly from the engine (see android/engine-patches/
|
||||
// 22-android-screen-profile.patch's hook into mainloop.c's
|
||||
// loopmode_switch(), and qs_config_load_startup() below for the initial
|
||||
// call) - runs on the engine's own thread already, so unlike every other
|
||||
// action in this file (all UI-thread-button-triggered), no cross-thread
|
||||
// SDL-event dispatch is needed here.
|
||||
void qs_screen_profile_notify_loop_mode(short new_mode) {
|
||||
bool isGame = new_mode != QS_SETUP_LOOP && new_mode != QS_CUTSCENE_LOOP;
|
||||
if (isGame == s_screen_profile_is_game)
|
||||
return;
|
||||
s_screen_profile_is_game = isGame;
|
||||
qs_screen_profile_reapply();
|
||||
}
|
||||
|
||||
static bool qs_apply_initial_screen_width(float width_m) {
|
||||
width_m = qs_clamp_screen_width(width_m);
|
||||
bool changed = s_initial_screen_width_m != width_m;
|
||||
s_initial_screen_width_m = width_m;
|
||||
if (changed && !s_screen_profile_is_game)
|
||||
qs_screen_profile_reapply();
|
||||
return changed;
|
||||
}
|
||||
|
||||
static bool qs_apply_game_screen_width(float width_m) {
|
||||
width_m = qs_clamp_screen_width(width_m);
|
||||
bool changed = s_game_screen_width_m != width_m;
|
||||
s_game_screen_width_m = width_m;
|
||||
if (changed && s_screen_profile_is_game)
|
||||
qs_screen_profile_reapply();
|
||||
return changed;
|
||||
}
|
||||
|
||||
static bool qs_apply_initial_screen_distance(float distance_m) {
|
||||
distance_m = qs_clamp_screen_distance(distance_m);
|
||||
bool changed = s_initial_screen_distance_m != distance_m;
|
||||
s_initial_screen_distance_m = distance_m;
|
||||
if (changed && !s_screen_profile_is_game)
|
||||
qs_screen_profile_reapply();
|
||||
return changed;
|
||||
}
|
||||
|
||||
static bool qs_apply_game_screen_distance(float distance_m) {
|
||||
distance_m = qs_clamp_screen_distance(distance_m);
|
||||
bool changed = s_game_screen_distance_m != distance_m;
|
||||
s_game_screen_distance_m = distance_m;
|
||||
if (changed && s_screen_profile_is_game)
|
||||
qs_screen_profile_reapply();
|
||||
return changed;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// General: language
|
||||
// ===========================================================================
|
||||
@@ -682,6 +898,38 @@ static bool qs_apply_player_position(const QsParsedPlayerPosition *parsed) {
|
||||
return changed;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// General: player controls
|
||||
// ===========================================================================
|
||||
|
||||
// Live-appliable: xr_input_set_action_binding() (xr_input.c) just writes
|
||||
// into the per-frame lookup table xr_input_sync_and_draw() already reads
|
||||
// every frame - no restart distinction needed, same as player_position.
|
||||
#define QS_PLAYER_CONTROLS_REQUIRES_RESTART false
|
||||
|
||||
// Applies only the actions present in *parsed and leaves the rest untouched.
|
||||
// Invalid raw-input names (a typo, or a symmetric form on the wrong action -
|
||||
// see xr_input_set_action_binding()) are WARN+skipped per action.
|
||||
static bool qs_apply_player_controls(const QsParsedPlayerControls *parsed) {
|
||||
bool changed = false;
|
||||
int count = xr_input_action_count();
|
||||
if (count > QS_MAX_PLAYER_CONTROL_ACTIONS)
|
||||
count = QS_MAX_PLAYER_CONTROL_ACTIONS;
|
||||
for (int i = 0; i < count; i++) {
|
||||
if (parsed->bindings[i][0] == '\0')
|
||||
continue;
|
||||
int before = xr_input_get_action_binding(i);
|
||||
if (!xr_input_set_action_binding(i, parsed->bindings[i])) {
|
||||
WARN("%s: player_controls.%s=\"%s\" is invalid - ignoring", QS_CONFIG_FILENAME,
|
||||
xr_input_action_name(i), parsed->bindings[i]);
|
||||
continue;
|
||||
}
|
||||
if (xr_input_get_action_binding(i) != before)
|
||||
changed = true;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Read-only queries for the Video Options tab (see qs_config.h)
|
||||
// ===========================================================================
|
||||
@@ -697,6 +945,45 @@ int qs_config_current_resolution_index(void) {
|
||||
|
||||
int qs_config_current_language(void) { return gShockPrefs.goLanguage; }
|
||||
|
||||
// -1 (no exact preset match) is a legitimate result for all four of these -
|
||||
// see qs_preset_index()'s own comment.
|
||||
int qs_config_current_initial_screen_width_index(void) {
|
||||
return qs_preset_index(s_screen_width_offset_m, s_screen_width_step_m, s_initial_screen_width_m);
|
||||
}
|
||||
int qs_config_current_initial_screen_distance_index(void) {
|
||||
return qs_preset_index(s_screen_distance_offset_m, s_screen_distance_step_m,
|
||||
s_initial_screen_distance_m);
|
||||
}
|
||||
int qs_config_current_game_screen_width_index(void) {
|
||||
return qs_preset_index(s_screen_width_offset_m, s_screen_width_step_m, s_game_screen_width_m);
|
||||
}
|
||||
int qs_config_current_game_screen_distance_index(void) {
|
||||
return qs_preset_index(s_screen_distance_offset_m, s_screen_distance_step_m,
|
||||
s_game_screen_distance_m);
|
||||
}
|
||||
|
||||
// Returns the value preset button `index` (0..QS_NUM_SCREEN_PRESETS-1)
|
||||
// currently shows for a given option - shared by both profiles' columns
|
||||
// (see the offset/step statics' own comment) - so MenuOverlay.java can
|
||||
// label its preset buttons correctly even after a config-file offset/step
|
||||
// change. Out-of-range index clamps into range rather than returning a
|
||||
// sentinel: unlike the index getters above, there's no "not present"
|
||||
// concept here - every index 0..4 always has a value.
|
||||
static int qs_clamp_preset_index(int index) {
|
||||
if (index < 0)
|
||||
return 0;
|
||||
if (index >= QS_NUM_SCREEN_PRESETS)
|
||||
return QS_NUM_SCREEN_PRESETS - 1;
|
||||
return index;
|
||||
}
|
||||
float qs_config_screen_width_preset_value(int index) {
|
||||
return qs_preset_value(s_screen_width_offset_m, s_screen_width_step_m, qs_clamp_preset_index(index));
|
||||
}
|
||||
float qs_config_screen_distance_preset_value(int index) {
|
||||
return qs_preset_value(s_screen_distance_offset_m, s_screen_distance_step_m,
|
||||
qs_clamp_preset_index(index));
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Apply / Save orchestration
|
||||
// ===========================================================================
|
||||
@@ -734,6 +1021,41 @@ static void qs_config_apply_parsed(const QsParsedConfig *parsed, bool live, char
|
||||
if (changed && QS_RESOLUTION_REQUIRES_RESTART)
|
||||
qs_append_restart_reason(restart_needed, restart_needed_size, "resolution");
|
||||
}
|
||||
if (parsed->video_options.has_initial_screen_width_m) {
|
||||
bool changed = qs_apply_initial_screen_width(parsed->video_options.initial_screen_width_m);
|
||||
if (changed && QS_SCREEN_PROFILE_REQUIRES_RESTART)
|
||||
qs_append_restart_reason(restart_needed, restart_needed_size, "initial_screen_width_m");
|
||||
}
|
||||
if (parsed->video_options.has_game_screen_width_m) {
|
||||
bool changed = qs_apply_game_screen_width(parsed->video_options.game_screen_width_m);
|
||||
if (changed && QS_SCREEN_PROFILE_REQUIRES_RESTART)
|
||||
qs_append_restart_reason(restart_needed, restart_needed_size, "game_screen_width_m");
|
||||
}
|
||||
if (parsed->video_options.has_initial_screen_distance_m) {
|
||||
bool changed =
|
||||
qs_apply_initial_screen_distance(parsed->video_options.initial_screen_distance_m);
|
||||
if (changed && QS_SCREEN_PROFILE_REQUIRES_RESTART)
|
||||
qs_append_restart_reason(restart_needed, restart_needed_size,
|
||||
"initial_screen_distance_m");
|
||||
}
|
||||
if (parsed->video_options.has_game_screen_distance_m) {
|
||||
bool changed =
|
||||
qs_apply_game_screen_distance(parsed->video_options.game_screen_distance_m);
|
||||
if (changed && QS_SCREEN_PROFILE_REQUIRES_RESTART)
|
||||
qs_append_restart_reason(restart_needed, restart_needed_size,
|
||||
"game_screen_distance_m");
|
||||
}
|
||||
// The four offset/step fields below only reshape what the preset
|
||||
// buttons offer (see their own statics' comment) - no live-reapply
|
||||
// or restart consideration needed, just a direct assignment.
|
||||
if (parsed->video_options.has_screen_width_offset_m)
|
||||
s_screen_width_offset_m = parsed->video_options.screen_width_offset_m;
|
||||
if (parsed->video_options.has_screen_width_step_m)
|
||||
s_screen_width_step_m = parsed->video_options.screen_width_step_m;
|
||||
if (parsed->video_options.has_screen_distance_offset_m)
|
||||
s_screen_distance_offset_m = parsed->video_options.screen_distance_offset_m;
|
||||
if (parsed->video_options.has_screen_distance_step_m)
|
||||
s_screen_distance_step_m = parsed->video_options.screen_distance_step_m;
|
||||
}
|
||||
if (parsed->has_general) {
|
||||
if (parsed->general.language[0] != '\0') {
|
||||
@@ -747,6 +1069,11 @@ static void qs_config_apply_parsed(const QsParsedConfig *parsed, bool live, char
|
||||
if (changed && QS_PLAYER_POSITION_REQUIRES_RESTART)
|
||||
qs_append_restart_reason(restart_needed, restart_needed_size, "player_position");
|
||||
}
|
||||
if (parsed->has_player_controls) {
|
||||
bool changed = qs_apply_player_controls(&parsed->player_controls);
|
||||
if (changed && QS_PLAYER_CONTROLS_REQUIRES_RESTART)
|
||||
qs_append_restart_reason(restart_needed, restart_needed_size, "player_controls");
|
||||
}
|
||||
}
|
||||
|
||||
static bool qs_config_write_file(void) {
|
||||
@@ -758,11 +1085,27 @@ static bool qs_config_write_file(void) {
|
||||
fprintf(f, "{\n");
|
||||
fprintf(f, " \"video_options\": {\n");
|
||||
fprintf(f, " \"renderer\": \"%s\",\n", qs_current_renderer());
|
||||
fprintf(f, " \"resolution\": \"%s\"\n", qs_current_resolution());
|
||||
fprintf(f, " \"resolution\": \"%s\",\n", qs_current_resolution());
|
||||
fprintf(f, " \"initial_screen_width_m\": %f,\n", s_initial_screen_width_m);
|
||||
fprintf(f, " \"initial_screen_distance_m\": %f,\n", s_initial_screen_distance_m);
|
||||
fprintf(f, " \"game_screen_width_m\": %f,\n", s_game_screen_width_m);
|
||||
fprintf(f, " \"game_screen_distance_m\": %f,\n", s_game_screen_distance_m);
|
||||
fprintf(f, " \"screen_width_offset_m\": %f,\n", s_screen_width_offset_m);
|
||||
fprintf(f, " \"screen_width_step_m\": %f,\n", s_screen_width_step_m);
|
||||
fprintf(f, " \"screen_distance_offset_m\": %f,\n", s_screen_distance_offset_m);
|
||||
fprintf(f, " \"screen_distance_step_m\": %f\n", s_screen_distance_step_m);
|
||||
fprintf(f, " },\n");
|
||||
fprintf(f, " \"general\": {\n");
|
||||
fprintf(f, " \"language\": \"%s\"\n", qs_current_language_name());
|
||||
fprintf(f, " },\n");
|
||||
{
|
||||
int count = xr_input_action_count();
|
||||
fprintf(f, " \"player_controls\": {\n");
|
||||
for (int i = 0; i < count; i++)
|
||||
fprintf(f, " \"%s\": \"%s\"%s\n", xr_input_action_name(i),
|
||||
xr_input_get_action_binding_name(i), i + 1 < count ? "," : "");
|
||||
fprintf(f, " },\n");
|
||||
}
|
||||
{
|
||||
float x, z, yaw_degrees;
|
||||
xr_session_get_player_transform(&x, &z, &yaw_degrees);
|
||||
@@ -843,6 +1186,52 @@ static void qs_show_restart_dialog(const char *restart_needed) {
|
||||
// only qs_apply_language()'s missing-resource-files warning above.
|
||||
static void qs_show_message_dialog(const char *message) { qs_call_dialog_method("showMessageDialog", message); }
|
||||
|
||||
// Non-cancelable, single-"Exit"-button dialog (MenuOverlay.java's
|
||||
// showFatalControllerBindingError()) - see qs_config_report_fatal_input_error()'s
|
||||
// own comment (qs_config.h) on when/why this fires.
|
||||
void qs_config_report_fatal_input_error(const char *profile_display_name) {
|
||||
char message[256];
|
||||
snprintf(message, sizeof(message),
|
||||
"Controller support failed to initialize: the OpenXR runtime rejected the \"%s\" "
|
||||
"interaction profile's bindings. This is an app bug, not something you can fix.",
|
||||
profile_display_name);
|
||||
qs_call_dialog_method("showFatalControllerBindingError", message);
|
||||
}
|
||||
|
||||
// Shared by qs_config_notify_control_capture_result() below - resolves
|
||||
// MenuOverlay.onControlCaptureResult(int, String), called once per changed
|
||||
// row (up to twice per capture - see this function's own header comment).
|
||||
static void qs_call_control_capture_result(int action_index, const char *raw_name) {
|
||||
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
|
||||
jobject activity = (jobject)SDL_AndroidGetActivity();
|
||||
if (!env || !activity)
|
||||
return;
|
||||
|
||||
jclass menuOverlayClass = qs_find_class(env, activity, "de.ladkau.questshock.MenuOverlay");
|
||||
if (!menuOverlayClass)
|
||||
return;
|
||||
|
||||
jmethodID method = (*env)->GetStaticMethodID(env, menuOverlayClass, "onControlCaptureResult",
|
||||
"(ILjava/lang/String;)V");
|
||||
if (!method) {
|
||||
(*env)->ExceptionClear(env);
|
||||
return;
|
||||
}
|
||||
|
||||
jstring rawNameStr = raw_name ? (*env)->NewStringUTF(env, raw_name) : NULL;
|
||||
(*env)->CallStaticVoidMethod(env, menuOverlayClass, method, (jint)action_index, rawNameStr);
|
||||
if ((*env)->ExceptionCheck(env))
|
||||
(*env)->ExceptionClear(env);
|
||||
}
|
||||
|
||||
void qs_config_notify_control_capture_result(int action_index, const char *raw_name,
|
||||
int displaced_action_index,
|
||||
const char *displaced_raw_name) {
|
||||
qs_call_control_capture_result(action_index, raw_name);
|
||||
if (displaced_action_index >= 0)
|
||||
qs_call_control_capture_result(displaced_action_index, displaced_raw_name);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Public API (see qs_config.h)
|
||||
// ===========================================================================
|
||||
@@ -853,24 +1242,47 @@ static Uint32 s_set_renderer_event_type;
|
||||
static Uint32 s_set_resolution_event_type;
|
||||
static Uint32 s_set_language_event_type;
|
||||
static Uint32 s_reset_position_event_type;
|
||||
static Uint32 s_set_initial_screen_width_event_type;
|
||||
static Uint32 s_set_initial_screen_distance_event_type;
|
||||
static Uint32 s_set_game_screen_width_event_type;
|
||||
static Uint32 s_set_game_screen_distance_event_type;
|
||||
static Uint32 s_begin_control_capture_event_type;
|
||||
static Uint32 s_cancel_control_capture_event_type;
|
||||
static Uint32 s_reset_player_controls_event_type;
|
||||
static Uint32 s_menu_visibility_event_type;
|
||||
static Uint32 s_cheat_event_type;
|
||||
|
||||
void qs_config_load_startup(void) {
|
||||
// Registers the custom SDL event types qs_config_request_save()/_load()/
|
||||
// _set_renderer()/_set_resolution()/_set_language()/_reset_position()
|
||||
// enqueue - done here, once, on the engine's own thread and before the
|
||||
// menu/its buttons could possibly exist yet, rather than lazily from
|
||||
// those functions themselves (which run on Android's UI thread and
|
||||
// could otherwise race each other doing this lazily).
|
||||
Uint32 base = SDL_RegisterEvents(6);
|
||||
// _set_renderer()/_set_resolution()/_set_language()/_reset_position()/
|
||||
// the four _set_*_screen_*() screen-profile setters/the three
|
||||
// control-capture/reset requests enqueue - done here, once, on the
|
||||
// engine's own thread and before the menu/its buttons could possibly
|
||||
// exist yet, rather than lazily from those functions themselves (which
|
||||
// run on Android's UI thread and could otherwise race each other doing
|
||||
// this lazily).
|
||||
Uint32 base = SDL_RegisterEvents(15);
|
||||
s_save_event_type = base;
|
||||
s_load_event_type = base + 1;
|
||||
s_set_renderer_event_type = base + 2;
|
||||
s_set_resolution_event_type = base + 3;
|
||||
s_set_language_event_type = base + 4;
|
||||
s_reset_position_event_type = base + 5;
|
||||
s_set_initial_screen_width_event_type = base + 6;
|
||||
s_set_game_screen_width_event_type = base + 7;
|
||||
s_set_initial_screen_distance_event_type = base + 8;
|
||||
s_set_game_screen_distance_event_type = base + 9;
|
||||
s_begin_control_capture_event_type = base + 10;
|
||||
s_cancel_control_capture_event_type = base + 11;
|
||||
s_reset_player_controls_event_type = base + 12;
|
||||
s_menu_visibility_event_type = base + 13;
|
||||
s_cheat_event_type = base + 14;
|
||||
|
||||
QsParsedConfig parsed;
|
||||
if (qs_config_read_file(&parsed))
|
||||
bool haveConfig = qs_config_read_file(&parsed);
|
||||
if (!haveConfig)
|
||||
haveConfig = qs_config_read_file_named(QS_CONFIG_DEFAULT_FILENAME, &parsed);
|
||||
if (haveConfig)
|
||||
qs_config_apply_parsed(&parsed, /*live=*/false, NULL, 0);
|
||||
|
||||
// A saved player_position only means anything if all three fields are
|
||||
@@ -884,6 +1296,19 @@ void qs_config_load_startup(void) {
|
||||
if (parsed.has_player_position && parsed.player_position.has_x &&
|
||||
parsed.player_position.has_z && parsed.player_position.has_yaw_degrees)
|
||||
xr_input_skip_startup_recenter();
|
||||
|
||||
// The engine's own startup state is always QS_SETUP_LOOP (mainloop.c:
|
||||
// `short _current_loop = SETUP_LOOP;`, Shock.c's main() never changes
|
||||
// it before entering mainloop()), matching s_screen_profile_is_game's
|
||||
// own default (false/"initial") - so calling
|
||||
// qs_screen_profile_notify_loop_mode() here would see "no change" and
|
||||
// skip actually applying anything (its whole point is to skip
|
||||
// redundant re-applies on a same-profile transition, but there's no
|
||||
// "previous" profile yet this early). Call the apply step directly
|
||||
// instead, unconditionally, so whichever profile's shape was just
|
||||
// loaded/defaulted above actually reaches xr_session.c before the
|
||||
// first frame renders.
|
||||
qs_screen_profile_reapply();
|
||||
}
|
||||
|
||||
void qs_config_request_save(void) {
|
||||
@@ -931,6 +1356,180 @@ void qs_config_request_reset_position(void) {
|
||||
SDL_PushEvent(&event);
|
||||
}
|
||||
|
||||
// Screen profile preset buttons (Video Options tab) - index into the
|
||||
// shared width/distance offset+step-generated presets (see
|
||||
// qs_preset_value()'s own comment), same enqueue-only reasoning as every
|
||||
// other button in this file.
|
||||
void qs_config_request_set_initial_screen_width(int preset_index) {
|
||||
SDL_Event event;
|
||||
SDL_zero(event);
|
||||
event.type = s_set_initial_screen_width_event_type;
|
||||
event.user.code = preset_index;
|
||||
SDL_PushEvent(&event);
|
||||
}
|
||||
|
||||
void qs_config_request_set_game_screen_width(int preset_index) {
|
||||
SDL_Event event;
|
||||
SDL_zero(event);
|
||||
event.type = s_set_game_screen_width_event_type;
|
||||
event.user.code = preset_index;
|
||||
SDL_PushEvent(&event);
|
||||
}
|
||||
|
||||
void qs_config_request_set_initial_screen_distance(int preset_index) {
|
||||
SDL_Event event;
|
||||
SDL_zero(event);
|
||||
event.type = s_set_initial_screen_distance_event_type;
|
||||
event.user.code = preset_index;
|
||||
SDL_PushEvent(&event);
|
||||
}
|
||||
|
||||
void qs_config_request_set_game_screen_distance(int preset_index) {
|
||||
SDL_Event event;
|
||||
SDL_zero(event);
|
||||
event.type = s_set_game_screen_distance_event_type;
|
||||
event.user.code = preset_index;
|
||||
SDL_PushEvent(&event);
|
||||
}
|
||||
|
||||
// Controller Options tab (see qs_config.h) - same enqueue-only reasoning as
|
||||
// every button above.
|
||||
void qs_config_request_begin_control_capture(int action_index) {
|
||||
SDL_Event event;
|
||||
SDL_zero(event);
|
||||
event.type = s_begin_control_capture_event_type;
|
||||
event.user.code = action_index;
|
||||
SDL_PushEvent(&event);
|
||||
}
|
||||
|
||||
void qs_config_request_cancel_control_capture(void) {
|
||||
SDL_Event event;
|
||||
SDL_zero(event);
|
||||
event.type = s_cancel_control_capture_event_type;
|
||||
SDL_PushEvent(&event);
|
||||
}
|
||||
|
||||
void qs_config_request_cheat(int cheat_id) {
|
||||
SDL_Event event;
|
||||
SDL_zero(event);
|
||||
event.type = s_cheat_event_type;
|
||||
event.user.code = cheat_id;
|
||||
SDL_PushEvent(&event);
|
||||
}
|
||||
|
||||
void qs_config_notify_menu_visibility(bool visible) {
|
||||
SDL_Event event;
|
||||
SDL_zero(event);
|
||||
event.type = s_menu_visibility_event_type;
|
||||
event.user.code = visible ? 1 : 0;
|
||||
SDL_PushEvent(&event);
|
||||
}
|
||||
|
||||
void qs_config_request_reset_player_controls(void) {
|
||||
SDL_Event event;
|
||||
SDL_zero(event);
|
||||
event.type = s_reset_player_controls_event_type;
|
||||
SDL_PushEvent(&event);
|
||||
}
|
||||
|
||||
// hkeyfunc.c - the same toggle the game's own "pause" hotkey ('p') calls,
|
||||
// which also handles the mouse capture/cursor/sound side of pausing.
|
||||
extern uchar game_paused;
|
||||
extern uchar pause_game_func(ushort keycode, uint32_t context, intptr_t data);
|
||||
|
||||
// True only while a pause this file caused is (as far as it knows) still in
|
||||
// effect - see qs_config_notify_menu_visibility().
|
||||
static bool s_menu_paused_game = false;
|
||||
|
||||
// s_screen_profile_is_game is "a game is running" (anything but the setup/
|
||||
// main-menu and cutscene loops, see qs_screen_profile_notify_loop_mode()).
|
||||
static void qs_apply_menu_pause(bool menu_visible) {
|
||||
if (menu_visible) {
|
||||
if (s_screen_profile_is_game && !game_paused) {
|
||||
pause_game_func(0, 0, 0);
|
||||
s_menu_paused_game = true;
|
||||
}
|
||||
} else if (s_menu_paused_game) {
|
||||
s_menu_paused_game = false;
|
||||
// Only toggle back if it's still paused - the game may have
|
||||
// unpaused itself (or the player did) while the menu was open, and
|
||||
// toggling then would pause it instead.
|
||||
if (game_paused)
|
||||
pause_game_func(0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// hkeyfunc.c - the engine's own cheat hotkey handlers, and the refill added by
|
||||
// android/engine-patches/26-android-cheat-refill.patch.
|
||||
extern uchar toggle_giveall_func(ushort keycode, uint32_t context, intptr_t data);
|
||||
extern uchar toggle_physics_func(ushort keycode, uint32_t context, intptr_t data);
|
||||
extern uchar toggle_up_level_func(ushort keycode, uint32_t context, intptr_t data);
|
||||
extern uchar toggle_down_level_func(ushort keycode, uint32_t context, intptr_t data);
|
||||
extern void qs_cheat_refill(int what);
|
||||
extern uchar physics_running;
|
||||
|
||||
#define QS_CHEAT_GIVE_ALL 0
|
||||
#define QS_CHEAT_PHYSICS 1
|
||||
#define QS_CHEAT_LEVEL_UP 2
|
||||
#define QS_CHEAT_LEVEL_DOWN 3
|
||||
#define QS_CHEAT_UNLIMITED_HEALTH 4
|
||||
#define QS_CHEAT_UNLIMITED_ENERGY 5
|
||||
#define QS_CHEAT_UNLIMITED_AMMO 6
|
||||
#define QS_CHEAT_UNLIMITED_ITEMS 7
|
||||
|
||||
// Bits of s_cheat_unlimited; the values match qs_cheat_refill() in patch 26.
|
||||
#define QS_CHEAT_MASK_HEALTH 1
|
||||
#define QS_CHEAT_MASK_ENERGY 2
|
||||
#define QS_CHEAT_MASK_AMMO 4
|
||||
#define QS_CHEAT_MASK_ITEMS 8
|
||||
|
||||
static int s_cheat_unlimited = 0;
|
||||
|
||||
int qs_config_cheat_unlimited_mask(void) { return s_cheat_unlimited; }
|
||||
bool qs_config_cheat_physics_running(void) { return physics_running != 0; }
|
||||
|
||||
void qs_config_frame_tick(void) {
|
||||
if (s_cheat_unlimited && s_screen_profile_is_game)
|
||||
qs_cheat_refill(s_cheat_unlimited);
|
||||
}
|
||||
|
||||
static void qs_apply_cheat(int cheat_id) {
|
||||
if (cheat_id >= QS_CHEAT_UNLIMITED_HEALTH && cheat_id <= QS_CHEAT_UNLIMITED_ITEMS) {
|
||||
static const struct {
|
||||
int mask;
|
||||
const char *on, *off;
|
||||
} kUnlimited[] = {
|
||||
{QS_CHEAT_MASK_HEALTH, "Unlimited health on", "Unlimited health off"},
|
||||
{QS_CHEAT_MASK_ENERGY, "Unlimited energy on", "Unlimited energy off"},
|
||||
{QS_CHEAT_MASK_AMMO, "Unlimited ammo on", "Unlimited ammo off"},
|
||||
{QS_CHEAT_MASK_ITEMS, "Unlimited items on", "Unlimited items off"},
|
||||
};
|
||||
const int i = cheat_id - QS_CHEAT_UNLIMITED_HEALTH;
|
||||
s_cheat_unlimited ^= kUnlimited[i].mask;
|
||||
message_info((s_cheat_unlimited & kUnlimited[i].mask) ? kUnlimited[i].on : kUnlimited[i].off);
|
||||
return;
|
||||
}
|
||||
// The engine's own handlers assume a running game (player state, levels).
|
||||
if (!s_screen_profile_is_game) {
|
||||
message_info("Cheats need a running game");
|
||||
return;
|
||||
}
|
||||
switch (cheat_id) {
|
||||
case QS_CHEAT_GIVE_ALL:
|
||||
toggle_giveall_func(0, 0, 0);
|
||||
break;
|
||||
case QS_CHEAT_PHYSICS:
|
||||
toggle_physics_func(0, 0, 0);
|
||||
break;
|
||||
case QS_CHEAT_LEVEL_UP:
|
||||
toggle_up_level_func(0, 0, 0);
|
||||
break;
|
||||
case QS_CHEAT_LEVEL_DOWN:
|
||||
toggle_down_level_func(0, 0, 0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
bool qs_config_handle_event(const SDL_Event *event) {
|
||||
if (event->type == s_save_event_type) {
|
||||
if (qs_config_write_file())
|
||||
@@ -970,17 +1569,68 @@ bool qs_config_handle_event(const SDL_Event *event) {
|
||||
return true;
|
||||
}
|
||||
if (event->type == s_reset_position_event_type) {
|
||||
// Deliberately not xr_session_set_player_transform(0,0,0) - see
|
||||
// xr_input_recenter_player_to_screen()'s own comment: a "return to
|
||||
// (0,0,0)" reset only undoes thumbstick locomotion, not any real,
|
||||
// physical drift/rotation since the session started, which is what
|
||||
// actually made this feel "slightly off" - recentering onto the
|
||||
// player's current real head pose fixes both at once, and is
|
||||
// unrelated to questshock.config.json (this event doesn't read or
|
||||
// write the config file at all, just like this one never has).
|
||||
xr_input_recenter_player_to_screen();
|
||||
// Re-anchors the position onto the player's current real head position
|
||||
// and keeps their facing (see xr_input_reset_player_position()). Does
|
||||
// not read or write questshock.config.json.
|
||||
xr_input_reset_player_position();
|
||||
message_info("Recentered");
|
||||
return true;
|
||||
}
|
||||
if (event->type == s_set_initial_screen_width_event_type) {
|
||||
int index = event->user.code;
|
||||
if (index >= 0 && index < QS_NUM_SCREEN_PRESETS)
|
||||
qs_apply_initial_screen_width(
|
||||
qs_preset_value(s_screen_width_offset_m, s_screen_width_step_m, index));
|
||||
return true;
|
||||
}
|
||||
if (event->type == s_set_game_screen_width_event_type) {
|
||||
int index = event->user.code;
|
||||
if (index >= 0 && index < QS_NUM_SCREEN_PRESETS)
|
||||
qs_apply_game_screen_width(
|
||||
qs_preset_value(s_screen_width_offset_m, s_screen_width_step_m, index));
|
||||
return true;
|
||||
}
|
||||
if (event->type == s_set_initial_screen_distance_event_type) {
|
||||
int index = event->user.code;
|
||||
if (index >= 0 && index < QS_NUM_SCREEN_PRESETS)
|
||||
qs_apply_initial_screen_distance(
|
||||
qs_preset_value(s_screen_distance_offset_m, s_screen_distance_step_m, index));
|
||||
return true;
|
||||
}
|
||||
if (event->type == s_set_game_screen_distance_event_type) {
|
||||
int index = event->user.code;
|
||||
if (index >= 0 && index < QS_NUM_SCREEN_PRESETS)
|
||||
qs_apply_game_screen_distance(
|
||||
qs_preset_value(s_screen_distance_offset_m, s_screen_distance_step_m, index));
|
||||
return true;
|
||||
}
|
||||
if (event->type == s_begin_control_capture_event_type) {
|
||||
xr_input_capture_begin(event->user.code);
|
||||
return true;
|
||||
}
|
||||
if (event->type == s_cancel_control_capture_event_type) {
|
||||
xr_input_capture_cancel();
|
||||
return true;
|
||||
}
|
||||
if (event->type == s_cheat_event_type) {
|
||||
qs_apply_cheat(event->user.code);
|
||||
return true;
|
||||
}
|
||||
if (event->type == s_menu_visibility_event_type) {
|
||||
qs_apply_menu_pause(event->user.code != 0);
|
||||
return true;
|
||||
}
|
||||
if (event->type == s_reset_player_controls_event_type) {
|
||||
// Built-in defaults first (they cover every action), then
|
||||
// questshock.config.default.json's player_controls group, if it has
|
||||
// one, overrides the actions it specifies.
|
||||
xr_input_reset_player_controls();
|
||||
QsParsedConfig defaultParsed;
|
||||
if (qs_config_read_file_named(QS_CONFIG_DEFAULT_FILENAME, &defaultParsed) &&
|
||||
defaultParsed.has_player_controls)
|
||||
qs_apply_player_controls(&defaultParsed.player_controls);
|
||||
message_info("Controller bindings reset to defaults");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ extern "C" {
|
||||
// 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
|
||||
// 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.
|
||||
//
|
||||
@@ -56,25 +56,93 @@ void qs_config_request_set_resolution(int resolution_index);
|
||||
// menu applies a language change (see qs_config.c's qs_apply_language()).
|
||||
void qs_config_request_set_language(int language_index);
|
||||
|
||||
// Same cross-thread-dispatch reasoning as the three above - called from the
|
||||
// Main Menu tab's "Reset Position" button. A pure, live "return to a sane
|
||||
// view of the game quad" action (see xr_input.h's own
|
||||
// xr_input_recenter_player_to_screen(), which this dispatches to) -
|
||||
// unrelated to questshock.config.json despite living in this file for the
|
||||
// cross-thread dispatch machinery: it never reads or writes the config
|
||||
// file, and isn't a "reset to (0,0,0)" - it recenters based on the
|
||||
// player's current real head pose instead.
|
||||
// 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);
|
||||
|
||||
// Returns true and handles the event if it's one of the six custom events
|
||||
// qs_config_request_save()/_load()/_set_renderer()/_set_resolution()/
|
||||
// _set_language()/_reset_position() enqueue - called from pump_events()
|
||||
// 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 own thread, once per polled event, before its normal switch on
|
||||
// well-known SDL_* event types. Returns false (no-op) for any other event
|
||||
// type, including before the six custom types have even been registered.
|
||||
// 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.
|
||||
@@ -87,6 +155,28 @@ bool qs_config_handle_event(const SDL_Event *event);
|
||||
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
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
#include "qs_splash.h"
|
||||
|
||||
#include <SDL.h>
|
||||
#include <android/log.h>
|
||||
#include <stdbool.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "xr_session.h"
|
||||
|
||||
#define QS_SPLASH_HOLD_MS 2500
|
||||
#define QS_SPLASH_FADE_MS 500
|
||||
#define QS_SPLASH_TOTAL_MS (QS_SPLASH_HOLD_MS + 2 * QS_SPLASH_FADE_MS)
|
||||
// The XR session only starts once frames are polled, so the display time is
|
||||
// counted from the first submitted frame; this bounds the wait for it.
|
||||
#define QS_SPLASH_SESSION_TIMEOUT_MS 5000
|
||||
|
||||
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, "QuestShock", __VA_ARGS__)
|
||||
|
||||
extern void pump_events(void);
|
||||
// OpenGL.cc (engine patch 11): draws a software frame into the XR game swapchain.
|
||||
extern void android_composite_software_frame(SDL_Surface *ui);
|
||||
|
||||
static SDL_Surface *s_image = NULL;
|
||||
|
||||
void qs_splash_set_image(const uint32_t *argb, int width, int height) {
|
||||
if (s_image != NULL) {
|
||||
SDL_FreeSurface(s_image);
|
||||
s_image = NULL;
|
||||
}
|
||||
if (argb == NULL || width <= 0 || height <= 0)
|
||||
return;
|
||||
s_image = SDL_CreateRGBSurfaceWithFormat(0, width, height, 32, SDL_PIXELFORMAT_ARGB8888);
|
||||
if (s_image == NULL)
|
||||
return;
|
||||
for (int y = 0; y < height; y++)
|
||||
memcpy((uint8_t *)s_image->pixels + (size_t)y * s_image->pitch, argb + (size_t)y * width,
|
||||
(size_t)width * sizeof(uint32_t));
|
||||
SDL_SetSurfaceBlendMode(s_image, SDL_BLENDMODE_BLEND);
|
||||
}
|
||||
|
||||
// 0-255 brightness at `elapsed` ms after the first frame.
|
||||
static int qs_splash_level(uint32_t elapsed) {
|
||||
uint32_t edge = elapsed < QS_SPLASH_TOTAL_MS - elapsed ? elapsed : QS_SPLASH_TOTAL_MS - elapsed;
|
||||
if (edge >= QS_SPLASH_FADE_MS)
|
||||
return 255;
|
||||
return (int)(255 * edge / QS_SPLASH_FADE_MS);
|
||||
}
|
||||
|
||||
void qs_splash_show(void) {
|
||||
if (s_image == NULL) {
|
||||
LOGI("Startup splash: no image");
|
||||
return;
|
||||
}
|
||||
int width, height;
|
||||
xr_get_game_swapchain_size(&width, &height);
|
||||
if (width <= 0 || height <= 0) {
|
||||
LOGI("Startup splash: no XR game swapchain");
|
||||
return;
|
||||
}
|
||||
|
||||
// The image is stretched over the whole swapchain image, so it is
|
||||
// letterboxed into a canvas of that size to keep its aspect ratio.
|
||||
SDL_Surface *canvas =
|
||||
SDL_CreateRGBSurfaceWithFormat(0, width, height, 32, SDL_PIXELFORMAT_RGBA32);
|
||||
if (canvas == NULL)
|
||||
return;
|
||||
const float scale = SDL_min((float)width / s_image->w, (float)height / s_image->h);
|
||||
SDL_Rect dst;
|
||||
dst.w = (int)(s_image->w * scale);
|
||||
dst.h = (int)(s_image->h * scale);
|
||||
dst.x = (width - dst.w) / 2;
|
||||
dst.y = (height - dst.h) / 2;
|
||||
|
||||
const uint32_t waitStart = SDL_GetTicks();
|
||||
uint32_t shownStart = 0;
|
||||
uint32_t elapsed = 0;
|
||||
int drawnLevel = -1;
|
||||
bool done = false;
|
||||
|
||||
while (!done) {
|
||||
pump_events();
|
||||
xr_poll_events();
|
||||
if (xr_frame_begin()) {
|
||||
if (shownStart == 0)
|
||||
shownStart = SDL_GetTicks();
|
||||
elapsed = SDL_GetTicks() - shownStart;
|
||||
if (elapsed >= QS_SPLASH_TOTAL_MS) {
|
||||
elapsed = QS_SPLASH_TOTAL_MS;
|
||||
done = true;
|
||||
}
|
||||
const int level = qs_splash_level(elapsed);
|
||||
if (level != drawnLevel) {
|
||||
drawnLevel = level;
|
||||
SDL_FillRect(canvas, NULL, SDL_MapRGB(canvas->format, 0, 0, 0));
|
||||
SDL_SetSurfaceColorMod(s_image, level, level, level);
|
||||
SDL_BlitScaled(s_image, NULL, canvas, &dst);
|
||||
}
|
||||
android_composite_software_frame(canvas);
|
||||
} else {
|
||||
SDL_Delay(10);
|
||||
if (shownStart == 0 && SDL_GetTicks() - waitStart > QS_SPLASH_SESSION_TIMEOUT_MS)
|
||||
done = true;
|
||||
}
|
||||
xr_frame_end();
|
||||
}
|
||||
|
||||
LOGI("Startup splash: %s after %u ms", shownStart != 0 ? "shown" : "XR session did not start",
|
||||
(unsigned)(SDL_GetTicks() - waitStart));
|
||||
SDL_FreeSurface(canvas);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Startup splash: the QuestShock title, shown on the game screen before the
|
||||
// engine's own splash sequence.
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
// Stores a copy of the splash image (straight-alpha ARGB, one int per pixel).
|
||||
// Called from JNI before the engine starts; replaces any earlier image. Without
|
||||
// an image qs_splash_show() does nothing.
|
||||
void qs_splash_set_image(const uint32_t *argb, int width, int height);
|
||||
|
||||
// Engine thread only (called from engine patch 27). Fades the image in and out
|
||||
// on the game screen and returns when done, or after a few seconds if the XR
|
||||
// session never starts.
|
||||
void qs_splash_show(void);
|
||||
@@ -11,6 +11,8 @@
|
||||
#include <SDL.h>
|
||||
|
||||
#include "qs_config.h"
|
||||
#include "qs_splash.h"
|
||||
#include "xr_input.h"
|
||||
#include "xr_overlay.h"
|
||||
#include "xr_session.h"
|
||||
|
||||
@@ -21,6 +23,15 @@ Java_de_ladkau_questshock_QuestShockActivity_nativeChdir(JNIEnv *env, jclass cla
|
||||
(*env)->ReleaseStringUTFChars(env, path, cpath);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL Java_de_ladkau_questshock_QuestShockActivity_nativeSetSplashImage(
|
||||
JNIEnv *env, jclass clazz, jintArray pixels, jint width, jint height) {
|
||||
jint *data = (*env)->GetIntArrayElements(env, pixels, NULL);
|
||||
if (data == NULL)
|
||||
return;
|
||||
qs_splash_set_image((const uint32_t *)data, width, height);
|
||||
(*env)->ReleaseIntArrayElements(env, pixels, data, JNI_ABORT);
|
||||
}
|
||||
|
||||
// KeyboardOverlay's hand-built on-screen keyboard (see
|
||||
// KeyboardOverlay.handleCharKeyDown()/handleCharKeyUp()) has no real
|
||||
// Android IME session behind it, so printable characters can't reach the
|
||||
@@ -132,6 +143,33 @@ Java_de_ladkau_questshock_MenuOverlay_nativeRequestResetPosition(JNIEnv *env, jc
|
||||
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
|
||||
@@ -144,6 +182,42 @@ Java_de_ladkau_questshock_MenuOverlay_nativeGetResolutionIndex(JNIEnv *env, jcla
|
||||
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();
|
||||
@@ -170,3 +244,55 @@ 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();
|
||||
}
|
||||
|
||||
@@ -67,27 +67,22 @@ void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw);
|
||||
bool xr_input_get_beam_layer(int hand, XrCompositionLayerQuad *out_quad);
|
||||
|
||||
// Recenters the player so the game quad ("virtual screen") appears directly
|
||||
// ahead of wherever they're currently really standing/facing - a pure,
|
||||
// live "return to a sane view of the screen" action, unrelated to any
|
||||
// stored/config value (unlike xr_session_set_player_transform()'s other
|
||||
// callers, this never reads or writes questshock.config.json - it's driven
|
||||
// entirely by the player's current real head pose, the same way
|
||||
// xr_input_sync_and_draw()'s own menu re-center is). Called from the Main
|
||||
// Menu tab's "Reset Position" button (via qs_config.c's cross-thread
|
||||
// dispatch - see qs_config_request_reset_position()), but doesn't touch
|
||||
// anything else qs_config.c owns; also called once automatically, from
|
||||
// inside xr_input_sync_and_draw() itself, on whichever frame first has a
|
||||
// valid head pose after startup (see g_pending_startup_recenter's own
|
||||
// comment) - but only as a *fallback* default for when there's no
|
||||
// complete saved player_position to fall back on instead (see
|
||||
// xr_input_skip_startup_recenter() below), since a config-loaded value
|
||||
// describes a transform relative to a *previous* session's real-space
|
||||
// origin, which is only meaningful if that previous position was actually
|
||||
// captured (LOCAL space's own origin isn't a stable point across
|
||||
// launches). Returns false (no-op otherwise) if no valid head pose was
|
||||
// available to recenter onto yet.
|
||||
// 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
|
||||
@@ -96,6 +91,60 @@ bool xr_input_recenter_player_to_screen(void);
|
||||
// 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
|
||||
|
||||
@@ -26,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
|
||||
@@ -41,16 +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"
|
||||
// 768x552 (was 512x256, then 768x512) - grown to fit the header/tabs/Video
|
||||
// Options content added on top of the original single "Keyboard" button,
|
||||
// then again to fit the stats ribbon below the tab bar; taller in world
|
||||
// space too since MENU_WIDTH_METERS (the physical size) stays fixed and
|
||||
// height_m is derived from it by aspect ratio (see xr_overlay.c's
|
||||
// xr_overlay_init()).
|
||||
// 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 552
|
||||
#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
|
||||
|
||||
@@ -123,6 +133,24 @@ static PFN_xrQueryPerformanceMetricsCounterMETA g_xrQueryPerformanceMetricsCount
|
||||
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;
|
||||
@@ -351,12 +379,24 @@ static bool xr_create_instance_and_session(int game_width, int game_height) {
|
||||
|
||||
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);
|
||||
|
||||
const char *extensions[3] = {
|
||||
// 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,
|
||||
XR_META_PERFORMANCE_METRICS_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;
|
||||
@@ -364,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 = g_performance_metrics_supported ? 3 : 2;
|
||||
createInfo.enabledExtensionCount = extensionCount;
|
||||
createInfo.enabledExtensionNames = extensions;
|
||||
strncpy(createInfo.applicationInfo.applicationName, "QuestShock",
|
||||
XR_MAX_APPLICATION_NAME_SIZE - 1);
|
||||
@@ -659,30 +699,54 @@ 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 the keyboard,
|
||||
// menu, or the game quad itself (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_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 = -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)) {
|
||||
@@ -766,13 +830,50 @@ void xr_shutdown(void) {
|
||||
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_swapchain_size(int *width, int *height) {
|
||||
*width = g_game_swapchain.width;
|
||||
*height = g_game_swapchain.height;
|
||||
}
|
||||
|
||||
void xr_get_game_resolution(int *width, int *height) {
|
||||
|
||||
@@ -68,11 +68,30 @@ void xr_frame_end(void);
|
||||
|
||||
void xr_shutdown(void);
|
||||
|
||||
// Play-space geometry of the single game quad xr_frame_end() submits
|
||||
// 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
|
||||
@@ -83,6 +102,12 @@ void xr_get_game_quad_extent(float *distance_m, float *half_width_m, float *half
|
||||
// swapchain.
|
||||
void xr_get_game_resolution(int *width, int *height);
|
||||
|
||||
// Pixel size of the game quad's swapchain image, fixed at launch (0, 0 before
|
||||
// xr_init()). The quad's aspect ratio is this size, so full-quad content
|
||||
// drawn outside the engine's own frame path (the startup splash) is laid out
|
||||
// against it.
|
||||
void xr_get_game_swapchain_size(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
|
||||
|
||||
@@ -271,7 +271,11 @@ abstract class OverlayPanel {
|
||||
// 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.
|
||||
// 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()).
|
||||
@@ -285,7 +289,10 @@ abstract class OverlayPanel {
|
||||
if (current == root || !(current.getParent() instanceof View)) {
|
||||
break;
|
||||
}
|
||||
current = (View) current.getParent();
|
||||
View parent = (View) current.getParent();
|
||||
left -= parent.getScrollX();
|
||||
top -= parent.getScrollY();
|
||||
current = parent;
|
||||
}
|
||||
return new Rect(left, top, left + view.getWidth(), top + view.getHeight());
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import android.app.AlertDialog;
|
||||
import android.content.Context;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.res.AssetManager;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.BitmapFactory;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import android.view.SurfaceHolder;
|
||||
@@ -18,7 +20,7 @@ import org.libsdl.app.SDLActivity;
|
||||
import org.libsdl.app.SDLSurface;
|
||||
|
||||
/**
|
||||
* Prepares /sdcard/questshock/ the way Shockolate's plain relative-path
|
||||
* Prepares /sdcard/QuestShock/ the way Shockolate's plain relative-path
|
||||
* file I/O ("res/data/...", confirmed via grep - none of it goes through
|
||||
* SDL_RWops, so Android's asset-manager fallback for SDL_RWFromFile
|
||||
* doesn't apply here) expects to find it - see android/engine-patches/ for
|
||||
@@ -39,7 +41,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())
|
||||
@@ -57,6 +60,9 @@ public class QuestShockActivity extends SDLActivity {
|
||||
// has no public chdir() (confirmed against the actual API 34 stub jar).
|
||||
private static native void nativeChdir(String path);
|
||||
|
||||
// Straight-alpha ARGB pixels of the startup splash (qs_splash.c).
|
||||
private static native void nativeSetSplashImage(int[] pixels, int width, int height);
|
||||
|
||||
@Override
|
||||
protected String[] getLibraries() {
|
||||
return new String[] {
|
||||
@@ -143,11 +149,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
|
||||
@@ -172,11 +192,31 @@ public class QuestShockActivity extends SDLActivity {
|
||||
// before clearing mBrokenLibraries below so the engine can never
|
||||
// start pre-chdir.
|
||||
nativeChdir(GAME_DIR);
|
||||
loadSplashImage();
|
||||
|
||||
// Assets confirmed present - safe to let the native engine start now.
|
||||
SDLActivity.mBrokenLibraries = false;
|
||||
}
|
||||
|
||||
// The engine shows this at startup (qs_splash.c); without it there is no splash.
|
||||
private void loadSplashImage() {
|
||||
try (InputStream in = getAssets().open("splash.png")) {
|
||||
Bitmap bitmap = BitmapFactory.decodeStream(in);
|
||||
if (bitmap == null) {
|
||||
Log.e(TAG, "Could not decode splash.png");
|
||||
return;
|
||||
}
|
||||
int width = bitmap.getWidth();
|
||||
int height = bitmap.getHeight();
|
||||
int[] pixels = new int[width * height];
|
||||
bitmap.getPixels(pixels, 0, width, 0, 0, width, height);
|
||||
bitmap.recycle();
|
||||
nativeSetSplashImage(pixels, width, height);
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "Could not read splash.png", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isNonEmptyDir(File dir) {
|
||||
String[] entries = dir.list();
|
||||
return entries != null && entries.length > 0;
|
||||
@@ -204,15 +244,15 @@ public class QuestShockActivity extends SDLActivity {
|
||||
+ "merging or extraction required.\n\n"
|
||||
+ "Then relaunch.\n\n"
|
||||
+ "(These instructions are also in GET_ASSETS_QUEST.txt in the "
|
||||
+ "questshock folder on this device, if you need to read them again.)")
|
||||
+ "QuestShock folder on this device, if you need to read them again.)")
|
||||
.setCancelable(false)
|
||||
.setPositiveButton("Exit", (dialog, which) -> finish())
|
||||
.create()
|
||||
.show();
|
||||
}
|
||||
|
||||
private void copyAssetFile(String assetPath, File dest) {
|
||||
if (dest.exists()) {
|
||||
private void copyAssetFile(String assetPath, File dest, boolean overwrite) {
|
||||
if (dest.exists() && !overwrite) {
|
||||
return;
|
||||
}
|
||||
File parent = dest.getParentFile();
|
||||
@@ -232,7 +272,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 {
|
||||
@@ -246,7 +286,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,10 @@ final class Strings {
|
||||
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.
|
||||
@@ -57,6 +61,145 @@ final class Strings {
|
||||
"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() {}
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 16 KiB |
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">System Shock</string>
|
||||
<string name="app_name">QuestShock</string>
|
||||
</resources>
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,24 @@
|
||||
--- a/src/GameSrc/setup.c 2026-09-20 16:04:46.197310679 +0200
|
||||
+++ b/src/GameSrc/setup.c 2026-09-20 16:05:08.400330327 +0200
|
||||
@@ -58,6 +58,9 @@
|
||||
#include "gamestrn.h"
|
||||
|
||||
#include "mainloop.h"
|
||||
+#ifdef __ANDROID__
|
||||
+#include "qs_splash.h"
|
||||
+#endif
|
||||
#include "tools.h"
|
||||
#include "input.h"
|
||||
#include "game_screen.h"
|
||||
@@ -1300,6 +1303,11 @@
|
||||
if (!show_splash)
|
||||
return;
|
||||
|
||||
+#ifdef __ANDROID__
|
||||
+ // The QuestShock title, before the engine's own splash screens.
|
||||
+ qs_splash_show();
|
||||
+#endif
|
||||
+
|
||||
// Need to load the splash palette file
|
||||
|
||||
INFO("Loading splshpal.res");
|
||||
@@ -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
|
||||
|
||||
@@ -24,16 +24,33 @@ VERSION="$("$REPO_ROOT/build-image/version.sh")"
|
||||
VERSION_CODE="$(git -C "$REPO_ROOT" rev-list --count HEAD)"
|
||||
echo "Version: $VERSION (versionCode $VERSION_CODE)"
|
||||
|
||||
echo "== Building the APK (gradlew assembleDebug) =="
|
||||
# A release APK is built and signed when QUESTSHOCK_KEYSTORE is set (with
|
||||
# QUESTSHOCK_KEYSTORE_PASSWORD, and optionally QUESTSHOCK_KEY_ALIAS,
|
||||
# default "questshock", and QUESTSHOCK_KEY_PASSWORD, default the keystore
|
||||
# password). Without it the APK is a debug build, signed with a per-machine
|
||||
# debug key.
|
||||
if [ -n "${QUESTSHOCK_KEYSTORE:-}" ]; then
|
||||
[ -f "$QUESTSHOCK_KEYSTORE" ] || { echo "BUILD FAIL: QUESTSHOCK_KEYSTORE ($QUESTSHOCK_KEYSTORE) does not exist" >&2; exit 1; }
|
||||
[ -n "${QUESTSHOCK_KEYSTORE_PASSWORD:-}" ] || { echo "BUILD FAIL: QUESTSHOCK_KEYSTORE_PASSWORD is not set" >&2; exit 1; }
|
||||
GRADLE_TASK=assembleRelease
|
||||
APK="$ANDROID_DIR/app/build/outputs/apk/release/app-release.apk"
|
||||
PKG_SUFFIX=""
|
||||
else
|
||||
echo "== QUESTSHOCK_KEYSTORE is not set: building a debug APK, not for distribution =="
|
||||
GRADLE_TASK=assembleDebug
|
||||
APK="$ANDROID_DIR/app/build/outputs/apk/debug/app-debug.apk"
|
||||
PKG_SUFFIX="-debug"
|
||||
fi
|
||||
|
||||
echo "== Building the APK (gradlew $GRADLE_TASK) =="
|
||||
cd "$ANDROID_DIR"
|
||||
./gradlew --no-daemon assembleDebug \
|
||||
./gradlew --no-daemon "$GRADLE_TASK" \
|
||||
"-PquestshockVersionName=$VERSION" "-PquestshockVersionCode=$VERSION_CODE"
|
||||
|
||||
APK="$ANDROID_DIR/app/build/outputs/apk/debug/app-debug.apk"
|
||||
[ -f "$APK" ] || { echo "BUILD FAIL: $APK not found after assembleDebug" >&2; exit 1; }
|
||||
[ -f "$APK" ] || { echo "BUILD FAIL: $APK not found after $GRADLE_TASK" >&2; exit 1; }
|
||||
|
||||
mkdir -p "$REPO_ROOT/dist"
|
||||
PKG_NAME="questshock-$VERSION-android-arm64.apk"
|
||||
PKG_NAME="questshock-$VERSION-android-arm64$PKG_SUFFIX.apk"
|
||||
cp "$APK" "$REPO_ROOT/dist/$PKG_NAME"
|
||||
|
||||
echo "== Done =="
|
||||
|
||||
@@ -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,9 @@ 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"
|
||||
cp "$REPO_ROOT/res/splash.png" "$ASSETS_DIR/splash.png"
|
||||
|
||||
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
|
||||
|
||||
@@ -5,8 +5,8 @@ installed (on Windows, or via Wine/Proton on Linux).
|
||||
|
||||
Using SideQuest (or any MTP file browser) with your Quest connected,
|
||||
copy its res/data/ and res/sound/ folders directly into:
|
||||
/sdcard/questshock/res/data/
|
||||
/sdcard/questshock/res/sound/
|
||||
/sdcard/QuestShock/res/data/
|
||||
/sdcard/QuestShock/res/sound/
|
||||
They already contain everything needed, in the right layout - no
|
||||
merging or extraction required.
|
||||
|
||||
|
||||
@@ -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}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 27 KiB |
@@ -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}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 62 KiB |
@@ -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}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 628 KiB After Width: | Height: | Size: 103 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 493 KiB After Width: | Height: | Size: 493 KiB |
|
Before Width: | Height: | Size: 434 KiB After Width: | Height: | Size: 434 KiB |
|
Before Width: | Height: | Size: 343 KiB After Width: | Height: | Size: 343 KiB |
|
Before Width: | Height: | Size: 272 KiB After Width: | Height: | Size: 272 KiB |
|
Before Width: | Height: | Size: 309 KiB After Width: | Height: | Size: 309 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 265 KiB After Width: | Height: | Size: 265 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 310 KiB After Width: | Height: | Size: 310 KiB |
|
Before Width: | Height: | Size: 297 KiB After Width: | Height: | Size: 297 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 402 KiB After Width: | Height: | Size: 402 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 641 KiB After Width: | Height: | Size: 641 KiB |
|
Before Width: | Height: | Size: 575 KiB After Width: | Height: | Size: 575 KiB |
|
Before Width: | Height: | Size: 707 KiB After Width: | Height: | Size: 707 KiB |
|
Before Width: | Height: | Size: 382 KiB After Width: | Height: | Size: 382 KiB |
|
Before Width: | Height: | Size: 413 KiB After Width: | Height: | Size: 413 KiB |
|
Before Width: | Height: | Size: 368 KiB After Width: | Height: | Size: 368 KiB |
|
Before Width: | Height: | Size: 435 KiB After Width: | Height: | Size: 435 KiB |
|
Before Width: | Height: | Size: 276 KiB After Width: | Height: | Size: 276 KiB |
|
Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 283 KiB |
|
After Width: | Height: | Size: 517 KiB |
|
After Width: | Height: | Size: 445 KiB |
|
After Width: | Height: | Size: 484 KiB |
|
After Width: | Height: | Size: 422 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 361 KiB |
|
After Width: | Height: | Size: 423 KiB |
|
After Width: | Height: | Size: 401 KiB |
|
After Width: | Height: | Size: 389 KiB |
@@ -1,132 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="210mm"
|
||||
height="297mm"
|
||||
viewBox="0 0 210 297"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
|
||||
sodipodi:docname="sketch_main_menu.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:zoom="2.5287798"
|
||||
inkscape:cx="167.47208"
|
||||
inkscape:cy="123.18194"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="962"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="33"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer1" />
|
||||
<defs
|
||||
id="defs1" />
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1">
|
||||
<rect
|
||||
style="fill:#000080;stroke-width:0.193167"
|
||||
id="rect1"
|
||||
width="63.682735"
|
||||
height="4.0229344"
|
||||
x="4.4946499"
|
||||
y="3.4182527" />
|
||||
<rect
|
||||
style="fill:#b3b3b3;stroke-width:0.543629"
|
||||
id="rect1-5"
|
||||
width="63.682735"
|
||||
height="31.862694"
|
||||
x="4.5049582"
|
||||
y="7.3757277" />
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:1.41111px;line-height:1.25;font-family:FreeSans;-inkscape-font-specification:FreeSans;stroke-width:0.264583"
|
||||
x="25.181696"
|
||||
y="20.839491"
|
||||
id="text2"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan2"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:FreeSans;-inkscape-font-specification:FreeSans;stroke-width:0.264583"
|
||||
x="25.181696"
|
||||
y="20.839491">Menu content here</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:1.41111px;line-height:1.25;font-family:FreeSans;-inkscape-font-specification:FreeSans;fill:#ffffff;stroke-width:0.264583"
|
||||
x="6.1205692"
|
||||
y="5.8075914"
|
||||
id="text3"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan3"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:FreeSans;-inkscape-font-specification:FreeSans;fill:#ffffff;stroke-width:0.264583"
|
||||
x="6.1205692"
|
||||
y="5.8075914">Questshock (v3.0.2)</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:1.41111px;line-height:1.25;font-family:FreeSans;-inkscape-font-specification:FreeSans;fill:#ffffff;stroke-width:0.264583"
|
||||
x="60.855247"
|
||||
y="5.7559261"
|
||||
id="text4"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan4"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:FreeSans;-inkscape-font-specification:FreeSans;fill:#ffffff;stroke-width:0.264583"
|
||||
x="60.855247"
|
||||
y="5.7559261">[FPS: 70]</tspan></text>
|
||||
<rect
|
||||
style="fill:#000080;stroke-width:0.156477"
|
||||
id="rect1-2"
|
||||
width="63.682735"
|
||||
height="2.6398373"
|
||||
x="4.471632"
|
||||
y="39.1059" />
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:1.41111px;line-height:1.25;font-family:FreeSans;-inkscape-font-specification:FreeSans;fill:#ffffff;stroke-width:0.264583"
|
||||
x="5.4945946"
|
||||
y="40.859875"
|
||||
id="text3-7"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan3-5"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:FreeSans;-inkscape-font-specification:FreeSans;fill:#ffffff;stroke-width:0.264583"
|
||||
x="5.4945946"
|
||||
y="40.859875">Main Menu</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:1.41111px;line-height:1.25;font-family:FreeSans;-inkscape-font-specification:FreeSans;fill:#ffffff;stroke-width:0.264583"
|
||||
x="13.718193"
|
||||
y="40.915668"
|
||||
id="text3-7-8"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan3-5-9"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:FreeSans;-inkscape-font-specification:FreeSans;fill:#ffffff;stroke-width:0.264583"
|
||||
x="13.718193"
|
||||
y="40.915668">Video Options</tspan></text>
|
||||
<rect
|
||||
style="fill:#1a1a1a;stroke:#ffffff;stroke-width:0.256016"
|
||||
id="rect4"
|
||||
width="0.055807162"
|
||||
height="2.4157476"
|
||||
x="12.918808"
|
||||
y="39.218266" />
|
||||
<rect
|
||||
style="fill:#1a1a1a;stroke:#ffffff;stroke-width:0.256016"
|
||||
id="rect4-1"
|
||||
width="0.055807162"
|
||||
height="2.4157476"
|
||||
x="22.843752"
|
||||
y="39.203552" />
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 166 KiB |