Compare commits
7 Commits
v0.4.0
...
790b4ffd21
| Author | SHA1 | Date | |
|---|---|---|---|
| 790b4ffd21 | |||
| 135e474a14 | |||
| 295f4869c1 | |||
| f0a83df667 | |||
| 9586c58a67 | |||
| 5d7564c3b5 | |||
| 70c43240bc |
@@ -69,7 +69,25 @@ jobs:
|
|||||||
dist/shockolate-*-linux-*.tar.gz
|
dist/shockolate-*-linux-*.tar.gz
|
||||||
dist/shockolate-*-windows-*.zip
|
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
|
- 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
|
# QUESTSHOCK_BUILD_IMAGE is already set (see Preflight above), so
|
||||||
# this runs build-apk.sh directly instead of trying to docker-run
|
# this runs build-apk.sh directly instead of trying to docker-run
|
||||||
# the image again - same reasoning as `make package` above.
|
# 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)
|
# Local registry credentials (see registry.env.example)
|
||||||
/registry.env
|
/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,7 +3,7 @@
|
|||||||
QuestShock can load a persistent, human-editable configuration file at
|
QuestShock can load a persistent, human-editable configuration file at
|
||||||
startup, in addition to (and independent of) the engine's own `prefs.txt`.
|
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
|
folder you already copy `res/data`/`res/sound` into (see README.md
|
||||||
section 5.1).
|
section 5.1).
|
||||||
- **Format**: plain JSON, grouped by concern. Each top-level key is a
|
- **Format**: plain JSON, grouped by concern. Each top-level key is a
|
||||||
@@ -174,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`/
|
open. See README.md section 3 ("Game assets") for where `mfdfrn.res`/
|
||||||
`mfdger.res` come from - the GOG Enhanced Edition installer alone doesn't
|
`mfdger.res` come from - the GOG Enhanced Edition installer alone doesn't
|
||||||
include them.
|
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`)
|
## Player Position (`player_position`)
|
||||||
|
|
||||||
@@ -230,6 +233,9 @@ wait 8 seconds, to cancel without changing it.
|
|||||||
- **Restart required?**: No (live-appliable) - a rebind takes effect
|
- **Restart required?**: No (live-appliable) - a rebind takes effect
|
||||||
immediately, and persists across restarts once saved (**Save
|
immediately, and persists across restarts once saved (**Save
|
||||||
Configuration**, same as every other group here).
|
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,
|
- **Not included here**: opening/closing the VR menu itself is a fixed,
|
||||||
non-remappable button (see "Menu button" below) - there's no
|
non-remappable button (see "Menu button" below) - there's no
|
||||||
`menu_toggle` field, and the Controller Options tab never lets you
|
`menu_toggle` field, and the Controller Options tab never lets you
|
||||||
@@ -358,7 +364,9 @@ keyboard (there is no hold behavior).
|
|||||||
- `keypad 0`-`keypad 9`: a controller button per digit is not useful; use the
|
- `keypad 0`-`keypad 9`: a controller button per digit is not useful; use the
|
||||||
on-screen keyboard.
|
on-screen keyboard.
|
||||||
- `toggle_freelook` (F): the free-view actions cover mouse look.
|
- `toggle_freelook` (F): the free-view actions cover mouse look.
|
||||||
- The 4 `cheat_*` entries: debug cheats.
|
- 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
|
- `mac_help` (disabled) and `audiolog_cancel` (its build flag is not
|
||||||
defined): they are not real hotkeys in this build.
|
defined): they are not real hotkeys in this build.
|
||||||
- `toggle_opengl` (Ctrl+G): the Video Options tab's Renderer buttons already
|
- `toggle_opengl` (Ctrl+G): the Video Options tab's Renderer buttons already
|
||||||
@@ -413,7 +421,7 @@ profile is active.
|
|||||||
|
|
||||||
The Controller Options tab shows a labeled illustration of whichever
|
The Controller Options tab shows a labeled illustration of whichever
|
||||||
controller is actually active, driven by `res/controller_profiles/*.json`
|
controller is actually active, driven by `res/controller_profiles/*.json`
|
||||||
(bundled with the app, copied to `/sdcard/questshock/controller_profiles/`
|
(bundled with the app, copied to `/sdcard/QuestShock/controller_profiles/`
|
||||||
on every launch, refreshing the known bundled files without touching any
|
on every launch, refreshing the known bundled files without touching any
|
||||||
extra ones you add there yourself). Each file:
|
extra ones you add there yourself). Each file:
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ and is not relicensed by this project's MIT license.
|
|||||||
|
|
||||||
Game assets are not included - see res/GET_ASSETS.txt.
|
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:
|
The Android build additionally bundles:
|
||||||
|
|
||||||
- GL4ES (https://github.com/ptitSeb/gl4es), vendored and patched (see
|
- 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),
|
headset, built on top of [Shockolate](https://github.com/Interrupt/systemshock),
|
||||||
a cross-platform port of the original game.
|
a cross-platform port of the original game.
|
||||||
|
|
||||||
|
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
|
## 1. Design principles
|
||||||
|
|
||||||
- **Standalone-headset-only.** The game must run entirely on the headset
|
- **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
|
"3. Game assets" below); `res/assets/extract_assets.sh` extracts it into
|
||||||
`ss_ee/`.
|
`ss_ee/`.
|
||||||
- `res/run.sh` - the launcher script, copied into `dist/` on build.
|
- `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:
|
- `Makefile` - targets:
|
||||||
- `all` (default) - alias for `dist`.
|
- `all` (default) - alias for `dist`.
|
||||||
- `build-image` - builds the Docker build-image (`./build-image.sh`).
|
- `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).
|
proprietary game assets (ships `res/GET_ASSETS.txt` in their place).
|
||||||
See "4.1. Packaging a distributable build" below.
|
See "4.1. Packaging a distributable build" below.
|
||||||
- `apk` - builds `dist/questshock-<version>-android-arm64.apk` for
|
- `apk` - builds `dist/questshock-<version>-android-arm64.apk` for
|
||||||
Android-based OpenXR headsets. Unlike every other target, this needs
|
Android-based OpenXR headsets (a debug build, `...-arm64-debug.apk`,
|
||||||
network access at build time (Gradle/AGP's own dependency
|
when there is no signing key - see "4.3. Releases and signing" below).
|
||||||
resolution). See "5. Android / Quest build" 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,
|
- `android-studio` - stages `engine/`/`android/gl4es-src/` (scratch,
|
||||||
patched copies) and the Android prebuilts with host-resolvable paths,
|
patched copies) and the Android prebuilts with host-resolvable paths,
|
||||||
so Android Studio can compile and deploy `android/` natively instead
|
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
|
entirely optionally - it still runs fine with just the Enhanced Edition
|
||||||
installer.
|
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
|
## 4. Desktop build
|
||||||
|
|
||||||
Builds and runs Questshock natively on the desktop (your dev machine, or
|
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
|
extra setup is needed in CI beyond the image itself), and publishes the
|
||||||
resulting tarball to dl.ladkau.de.
|
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
|
## 5. Android / Quest build
|
||||||
|
|
||||||
**Prerequisites:**
|
**Prerequisites:**
|
||||||
@@ -205,7 +313,8 @@ resulting tarball to dl.ladkau.de.
|
|||||||
apk` needs its own separate toolchain - see "5.2. Building natively in
|
apk` needs its own separate toolchain - see "5.2. Building natively in
|
||||||
Android Studio" below.
|
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
|
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
|
can be sideloaded onto any Android-based VR headset with OpenXR support
|
||||||
(Meta Quest, Pico, etc. - see "1. Design principles" above), not just one
|
(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
|
interact with it, same as the game's own Bluetooth mouse/keyboard input
|
||||||
otherwise works unchanged.
|
otherwise works unchanged.
|
||||||
|
|
||||||
The menu launcher (`MenuOverlay.java` - just a "Keyboard" button so far)
|
The menu (`MenuOverlay.java`, described in "5.1.1. Menu, controls and
|
||||||
and the keyboard (`KeyboardOverlay.java`) are two fully independent quads,
|
cheats" below) and the keyboard (`KeyboardOverlay.java`) are two fully
|
||||||
each its own `XrOverlay` instance (`android/app/src/main/cpp/xr_overlay.c`
|
independent quads, each its own `XrOverlay` instance
|
||||||
- a generic "off-screen Android View rendered into an OpenXR quad,
|
(`android/app/src/main/cpp/xr_overlay.c` - a generic "off-screen Android View
|
||||||
hit-tested via a laser pointer" module shared by both, and by whatever
|
rendered into an OpenXR quad, hit-tested via a laser pointer" module shared by
|
||||||
similar panel comes next) with its own swapchain, visibility, and
|
both) with its own swapchain, visibility, and position, so they can be
|
||||||
position, so they can be shown/hidden/moved independently - a design
|
shown/hidden/moved independently - the keyboard can stay open while also
|
||||||
specifically meant to let the keyboard stay open while also picking
|
picking something from the menu. The controller's menu button only ever
|
||||||
something from the (still-to-be-built-out) menu. The controller's menu
|
opens/closes the menu; the menu's "Keyboard" button only opens the keyboard
|
||||||
button only ever opens/closes the launcher; clicking its "Keyboard" button
|
(never touching the menu's own visibility) - the keyboard's own title bar
|
||||||
only opens the keyboard (never touching the launcher's own visibility) -
|
holds the only way to close it again.
|
||||||
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
|
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
|
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
|
1. Install the APK with [SideQuest](https://sidequestvr.com/) (or `adb
|
||||||
install`).
|
install`).
|
||||||
2. Launch it once. It'll ask for storage permission, then create
|
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
|
default MIDI soundfont) there - `res/data/` and `res/sound/` are
|
||||||
deliberately left missing, since that's the proprietary game data.
|
deliberately left missing, since that's the proprietary game data.
|
||||||
3. With the headset connected to a PC, use SideQuest's file browser (or
|
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/`
|
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
|
for exactly what's needed and where it comes from) into
|
||||||
`/sdcard/questshock/res/`.
|
`/sdcard/QuestShock/res/`.
|
||||||
4. Launch it again.
|
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
|
`questshock.config.json`, loaded automatically at startup and saved/loaded
|
||||||
from the in-headset menu - see [CONFIG.md](CONFIG.md) for the documented
|
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
|
### 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
|
Android Studio builds, installs, and launches the app on the headset
|
||||||
directly. No manual `adb install` or SideQuest step needed for this
|
directly. No manual `adb install` or SideQuest step needed for this
|
||||||
loop.
|
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
|
persists across reinstalls from Android Studio, so this loop doesn't
|
||||||
require re-copying `res/data/`/`res/sound/` on every iteration - only
|
require re-copying `res/data/`/`res/sound/` on every iteration - only
|
||||||
a full uninstall or wiping that directory clears it.
|
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
|
`qs_config_load_startup()` in `Shock.c`'s `main()`, right after
|
||||||
`LoadPrefs()`, and one check in `sdl_events.c`'s `pump_events()` to drain
|
`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.
|
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/`:
|
`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
|
and is *not* relicensed by this project's MIT license. Any build or
|
||||||
distribution of the compiled engine must comply with the GPLv3.
|
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,
|
The game assets extracted into `res/assets/ss_ee/` are proprietary,
|
||||||
copyrighted game data owned by their respective rightsholders - they are
|
copyrighted game data owned by their respective rightsholders - they are
|
||||||
never committed to this repository (see `.gitignore`) and must be
|
never committed to this repository (see `.gitignore`) and must be
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ def stageEngineTask = tasks.register("stageEngine", Exec) {
|
|||||||
// Staged into assets/ by prepare-android-project.sh.
|
// Staged into assets/ by prepare-android-project.sh.
|
||||||
inputs.file("${repoRoot}/res/questshock.config.default.json")
|
inputs.file("${repoRoot}/res/questshock.config.default.json")
|
||||||
inputs.dir("${repoRoot}/res/controller_profiles")
|
inputs.dir("${repoRoot}/res/controller_profiles")
|
||||||
|
inputs.file("${repoRoot}/res/splash.png")
|
||||||
outputs.dir("${repoRoot}/build/android-engine")
|
outputs.dir("${repoRoot}/build/android-engine")
|
||||||
outputs.dir("${repoRoot}/build/android-gl4es")
|
outputs.dir("${repoRoot}/build/android-gl4es")
|
||||||
outputs.dir("${repoRoot}/build/android-prebuilt")
|
outputs.dir("${repoRoot}/build/android-prebuilt")
|
||||||
@@ -138,7 +139,7 @@ android {
|
|||||||
// 29, not compileSdk's 34, is deliberate - see build-image/Dockerfile's
|
// 29, not compileSdk's 34, is deliberate - see build-image/Dockerfile's
|
||||||
// comment on ANDROID_PLATFORM_VERSION: paired with
|
// comment on ANDROID_PLATFORM_VERSION: paired with
|
||||||
// requestLegacyExternalStorage in the manifest, this keeps
|
// 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
|
// Android 11+ scoped storage, on the Quest 2/3/3S/Pro headsets this
|
||||||
// targets - not just whatever's the current API level today.
|
// targets - not just whatever's the current API level today.
|
||||||
minSdkVersion 24
|
minSdkVersion 24
|
||||||
@@ -199,7 +200,7 @@ android {
|
|||||||
"-DCMAKE_FIND_ROOT_PATH_MODE_LIBRARY=BOTH", \
|
"-DCMAKE_FIND_ROOT_PATH_MODE_LIBRARY=BOTH", \
|
||||||
"-DCMAKE_FIND_ROOT_PATH_MODE_INCLUDE=BOTH", \
|
"-DCMAKE_FIND_ROOT_PATH_MODE_INCLUDE=BOTH", \
|
||||||
"-DCMAKE_SHARED_LINKER_FLAGS=-Wl,-z,max-page-size=16384", \
|
"-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'
|
abiFilters 'arm64-v8a'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,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 {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
minifyEnabled false
|
minifyEnabled false
|
||||||
|
if (releaseKeystore) {
|
||||||
|
signingConfig signingConfigs.release
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,11 @@
|
|||||||
OpenXR session to actually start. -->
|
OpenXR session to actually start. -->
|
||||||
<meta-data android:name="com.oculus.vr.focusaware" android:value="true" />
|
<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"
|
<activity android:name="de.ladkau.questshock.QuestShockActivity"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:alwaysRetainTaskState="true"
|
android:alwaysRetainTaskState="true"
|
||||||
@@ -51,13 +56,10 @@
|
|||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
<!-- The vendor-neutral, Khronos-defined way to declare an
|
<!-- The vendor-neutral, Khronos-defined way to declare an
|
||||||
immersive OpenXR entry point (vs. e.g. Meta's own
|
immersive OpenXR entry point, plus Meta's own VR
|
||||||
com.oculus.intent.category.VR) - see this project's
|
category, which Horizon OS looks for to treat the app as
|
||||||
"favor OpenXR" rule in the README. If Horizon OS still
|
a VR app. Other runtimes ignore the Meta one. -->
|
||||||
launches this as a flat panel on-device, the next thing
|
<category android:name="com.oculus.intent.category.VR" />
|
||||||
to try is adding a com.oculus.supportedDevices meta-data
|
|
||||||
listing the target Quest models - not confirmed
|
|
||||||
necessary from static analysis alone. -->
|
|
||||||
<category android:name="org.khronos.openxr.intent.category.IMMERSIVE_HMD" />
|
<category android:name="org.khronos.openxr.intent.category.IMMERSIVE_HMD" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
|
|||||||
@@ -1250,6 +1250,7 @@ static Uint32 s_begin_control_capture_event_type;
|
|||||||
static Uint32 s_cancel_control_capture_event_type;
|
static Uint32 s_cancel_control_capture_event_type;
|
||||||
static Uint32 s_reset_player_controls_event_type;
|
static Uint32 s_reset_player_controls_event_type;
|
||||||
static Uint32 s_menu_visibility_event_type;
|
static Uint32 s_menu_visibility_event_type;
|
||||||
|
static Uint32 s_cheat_event_type;
|
||||||
|
|
||||||
void qs_config_load_startup(void) {
|
void qs_config_load_startup(void) {
|
||||||
// Registers the custom SDL event types qs_config_request_save()/_load()/
|
// Registers the custom SDL event types qs_config_request_save()/_load()/
|
||||||
@@ -1260,7 +1261,7 @@ void qs_config_load_startup(void) {
|
|||||||
// exist yet, rather than lazily from those functions themselves (which
|
// exist yet, rather than lazily from those functions themselves (which
|
||||||
// run on Android's UI thread and could otherwise race each other doing
|
// run on Android's UI thread and could otherwise race each other doing
|
||||||
// this lazily).
|
// this lazily).
|
||||||
Uint32 base = SDL_RegisterEvents(14);
|
Uint32 base = SDL_RegisterEvents(15);
|
||||||
s_save_event_type = base;
|
s_save_event_type = base;
|
||||||
s_load_event_type = base + 1;
|
s_load_event_type = base + 1;
|
||||||
s_set_renderer_event_type = base + 2;
|
s_set_renderer_event_type = base + 2;
|
||||||
@@ -1275,6 +1276,7 @@ void qs_config_load_startup(void) {
|
|||||||
s_cancel_control_capture_event_type = base + 11;
|
s_cancel_control_capture_event_type = base + 11;
|
||||||
s_reset_player_controls_event_type = base + 12;
|
s_reset_player_controls_event_type = base + 12;
|
||||||
s_menu_visibility_event_type = base + 13;
|
s_menu_visibility_event_type = base + 13;
|
||||||
|
s_cheat_event_type = base + 14;
|
||||||
|
|
||||||
QsParsedConfig parsed;
|
QsParsedConfig parsed;
|
||||||
bool haveConfig = qs_config_read_file(&parsed);
|
bool haveConfig = qs_config_read_file(&parsed);
|
||||||
@@ -1407,6 +1409,14 @@ void qs_config_request_cancel_control_capture(void) {
|
|||||||
SDL_PushEvent(&event);
|
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) {
|
void qs_config_notify_menu_visibility(bool visible) {
|
||||||
SDL_Event event;
|
SDL_Event event;
|
||||||
SDL_zero(event);
|
SDL_zero(event);
|
||||||
@@ -1449,6 +1459,77 @@ static void qs_apply_menu_pause(bool menu_visible) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
bool qs_config_handle_event(const SDL_Event *event) {
|
||||||
if (event->type == s_save_event_type) {
|
if (event->type == s_save_event_type) {
|
||||||
if (qs_config_write_file())
|
if (qs_config_write_file())
|
||||||
@@ -1531,6 +1612,10 @@ bool qs_config_handle_event(const SDL_Event *event) {
|
|||||||
xr_input_capture_cancel();
|
xr_input_capture_cancel();
|
||||||
return true;
|
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) {
|
if (event->type == s_menu_visibility_event_type) {
|
||||||
qs_apply_menu_pause(event->user.code != 0);
|
qs_apply_menu_pause(event->user.code != 0);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ extern "C" {
|
|||||||
// Persistent, human-editable JSON config file - see CONFIG.md at the repo
|
// Persistent, human-editable JSON config file - see CONFIG.md at the repo
|
||||||
// root for the documented schema. Lives at a plain relative path
|
// root for the documented schema. Lives at a plain relative path
|
||||||
// ("questshock.config.json") since the whole engine already runs with its
|
// ("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
|
// QuestShockActivity.java's nativeChdir()) before main() ever runs - the
|
||||||
// same way prefs.txt/keybinds.txt (Prefs.c) already do.
|
// same way prefs.txt/keybinds.txt (Prefs.c) already do.
|
||||||
//
|
//
|
||||||
@@ -104,6 +104,20 @@ void qs_config_notify_control_capture_result(int action_index, const char *raw_n
|
|||||||
// engine's thread at pump_events() time.
|
// engine's thread at pump_events() time.
|
||||||
void qs_config_notify_menu_visibility(bool visible);
|
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
|
// Called from xr_input.c's xr_input_suggest_bindings() when the OpenXR runtime
|
||||||
// rejects a supported interaction profile's suggested bindings at startup.
|
// rejects a supported interaction profile's suggested bindings at startup.
|
||||||
// That is an app bug, not a config problem, so it shows
|
// That is an app bug, not a config problem, so it shows
|
||||||
|
|||||||
@@ -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,7 @@
|
|||||||
#include <SDL.h>
|
#include <SDL.h>
|
||||||
|
|
||||||
#include "qs_config.h"
|
#include "qs_config.h"
|
||||||
|
#include "qs_splash.h"
|
||||||
#include "xr_input.h"
|
#include "xr_input.h"
|
||||||
#include "xr_overlay.h"
|
#include "xr_overlay.h"
|
||||||
#include "xr_session.h"
|
#include "xr_session.h"
|
||||||
@@ -22,6 +23,15 @@ Java_de_ladkau_questshock_QuestShockActivity_nativeChdir(JNIEnv *env, jclass cla
|
|||||||
(*env)->ReleaseStringUTFChars(env, path, cpath);
|
(*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's hand-built on-screen keyboard (see
|
||||||
// KeyboardOverlay.handleCharKeyDown()/handleCharKeyUp()) has no real
|
// KeyboardOverlay.handleCharKeyDown()/handleCharKeyUp()) has no real
|
||||||
// Android IME session behind it, so printable characters can't reach the
|
// Android IME session behind it, so printable characters can't reach the
|
||||||
@@ -270,3 +280,19 @@ Java_de_ladkau_questshock_MenuOverlay_nativeSetControllerOptionsTabOpen(JNIEnv *
|
|||||||
jboolean open) {
|
jboolean open) {
|
||||||
xr_input_set_controller_options_tab_open(open == JNI_TRUE);
|
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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -2000,6 +2000,7 @@ void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw) {
|
|||||||
(menuVisible || freeView[xr_input_raw_input_hand(raw)])) &&
|
(menuVisible || freeView[xr_input_raw_input_hand(raw)])) &&
|
||||||
g_raw_down[raw];
|
g_raw_down[raw];
|
||||||
}
|
}
|
||||||
|
qs_config_frame_tick();
|
||||||
xr_input_update_cyber_scales(wantDown);
|
xr_input_update_cyber_scales(wantDown);
|
||||||
xr_input_apply_free_view(freeView);
|
xr_input_apply_free_view(freeView);
|
||||||
for (size_t i = 0; i < QS_NUM_KEY_ACTIONS; i++) {
|
for (size_t i = 0; i < QS_NUM_KEY_ACTIONS; i++) {
|
||||||
|
|||||||
@@ -871,6 +871,11 @@ void xr_get_game_quad_extent(float *distance_m, float *half_width_m, float *half
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
void xr_get_game_resolution(int *width, int *height) {
|
||||||
// Not g_game_swapchain.width/height: that's the fixed physical size of
|
// Not g_game_swapchain.width/height: that's the fixed physical size of
|
||||||
// the swapchain image the game is composited into (see xr_init()) -
|
// the swapchain image the game is composited into (see xr_init()) -
|
||||||
|
|||||||
@@ -102,6 +102,12 @@ void xr_session_set_game_screen_shape(float width_m, float curve_deg, float dist
|
|||||||
// swapchain.
|
// swapchain.
|
||||||
void xr_get_game_resolution(int *width, int *height);
|
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
|
// Exponentially-smoothed frame rate/frame time, updated once per real
|
||||||
// rendered frame in xr_frame_end() - read by MenuOverlay's header (see
|
// rendered frame in xr_frame_end() - read by MenuOverlay's header (see
|
||||||
// questshock_native.c's nativeGetFps()/nativeGetFrameTimeMs()). 0 before
|
// questshock_native.c's nativeGetFps()/nativeGetFrameTimeMs()). 0 before
|
||||||
|
|||||||
@@ -143,14 +143,27 @@ public class MenuOverlay extends OverlayPanel {
|
|||||||
private TextView statsView;
|
private TextView statsView;
|
||||||
|
|
||||||
// Content area (tabs) - only one of mainMenuTab/videoOptionsTab/
|
// Content area (tabs) - only one of mainMenuTab/videoOptionsTab/
|
||||||
// controllerOptionsTab is VISIBLE at a time; currentTab tracks which.
|
// controllerOptionsTab/cheatsTab is VISIBLE at a time; currentTab tracks which.
|
||||||
private View mainMenuTab;
|
private View mainMenuTab;
|
||||||
private View videoOptionsTab;
|
private View videoOptionsTab;
|
||||||
private View controllerOptionsTab;
|
private View controllerOptionsTab;
|
||||||
|
private View cheatsTab;
|
||||||
private View currentTab;
|
private View currentTab;
|
||||||
private Button mainMenuTabButton;
|
private Button mainMenuTabButton;
|
||||||
private Button videoOptionsTabButton;
|
private Button videoOptionsTabButton;
|
||||||
private Button controllerOptionsTabButton;
|
private Button controllerOptionsTabButton;
|
||||||
|
private Button cheatsTabButton;
|
||||||
|
|
||||||
|
// Cheats tab
|
||||||
|
private TextView cheatsNoteView;
|
||||||
|
private Button giveAllButton;
|
||||||
|
private Button physicsButton;
|
||||||
|
private Button levelUpButton;
|
||||||
|
private Button levelDownButton;
|
||||||
|
private Button unlimitedHealthButton;
|
||||||
|
private Button unlimitedEnergyButton;
|
||||||
|
private Button unlimitedAmmoButton;
|
||||||
|
private Button unlimitedItemsButton;
|
||||||
|
|
||||||
// Main Menu tab's own two states (only one VISIBLE at a time, within
|
// Main Menu tab's own two states (only one VISIBLE at a time, within
|
||||||
// mainMenuTab) - mainMenuContent is the normal state (button row +
|
// mainMenuTab) - mainMenuContent is the normal state (button row +
|
||||||
@@ -251,14 +264,18 @@ public class MenuOverlay extends OverlayPanel {
|
|||||||
mainMenuTab = buildMainMenuTab();
|
mainMenuTab = buildMainMenuTab();
|
||||||
videoOptionsTab = buildVideoOptionsTab();
|
videoOptionsTab = buildVideoOptionsTab();
|
||||||
controllerOptionsTab = buildControllerOptionsTab();
|
controllerOptionsTab = buildControllerOptionsTab();
|
||||||
|
cheatsTab = buildCheatsTab();
|
||||||
content.addView(mainMenuTab, new FrameLayout.LayoutParams(
|
content.addView(mainMenuTab, new FrameLayout.LayoutParams(
|
||||||
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
|
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
|
||||||
content.addView(videoOptionsTab, new FrameLayout.LayoutParams(
|
content.addView(videoOptionsTab, new FrameLayout.LayoutParams(
|
||||||
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
|
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
|
||||||
content.addView(controllerOptionsTab, new FrameLayout.LayoutParams(
|
content.addView(controllerOptionsTab, new FrameLayout.LayoutParams(
|
||||||
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
|
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
|
||||||
|
content.addView(cheatsTab, new FrameLayout.LayoutParams(
|
||||||
|
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
|
||||||
videoOptionsTab.setVisibility(View.GONE);
|
videoOptionsTab.setVisibility(View.GONE);
|
||||||
controllerOptionsTab.setVisibility(View.GONE);
|
controllerOptionsTab.setVisibility(View.GONE);
|
||||||
|
cheatsTab.setVisibility(View.GONE);
|
||||||
currentTab = mainMenuTab;
|
currentTab = mainMenuTab;
|
||||||
panel.addView(content, new LinearLayout.LayoutParams(
|
panel.addView(content, new LinearLayout.LayoutParams(
|
||||||
LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f));
|
LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f));
|
||||||
@@ -923,6 +940,86 @@ public class MenuOverlay extends OverlayPanel {
|
|||||||
return profile.image;
|
return profile.image;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cheats tab: the engine's own cheat functions as buttons, plus four switches
|
||||||
|
// for unlimited health, energy, ammo and items. The physics and unlimited
|
||||||
|
// buttons show their state through the selected highlight.
|
||||||
|
private static final int CHEAT_GIVE_ALL = 0;
|
||||||
|
private static final int CHEAT_PHYSICS = 1;
|
||||||
|
private static final int CHEAT_LEVEL_UP = 2;
|
||||||
|
private static final int CHEAT_LEVEL_DOWN = 3;
|
||||||
|
private static final int CHEAT_UNLIMITED_HEALTH = 4;
|
||||||
|
private static final int CHEAT_UNLIMITED_ENERGY = 5;
|
||||||
|
private static final int CHEAT_UNLIMITED_AMMO = 6;
|
||||||
|
private static final int CHEAT_UNLIMITED_ITEMS = 7;
|
||||||
|
|
||||||
|
private View buildCheatsTab() {
|
||||||
|
LinearLayout tab = new LinearLayout(activity);
|
||||||
|
tab.setOrientation(LinearLayout.VERTICAL);
|
||||||
|
tab.setGravity(Gravity.CENTER);
|
||||||
|
tab.setPadding(24, 16, 24, 16);
|
||||||
|
|
||||||
|
cheatsNoteView = new TextView(activity);
|
||||||
|
cheatsNoteView.setTextColor(0xFFFFD060);
|
||||||
|
cheatsNoteView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 15);
|
||||||
|
cheatsNoteView.setGravity(Gravity.CENTER);
|
||||||
|
cheatsNoteView.setPadding(0, 0, 0, 16);
|
||||||
|
cheatsNoteView.setText(Strings.get("cheats_note", currentLanguage));
|
||||||
|
tab.addView(cheatsNoteView);
|
||||||
|
|
||||||
|
LinearLayout firstRow = new LinearLayout(activity);
|
||||||
|
firstRow.setOrientation(LinearLayout.HORIZONTAL);
|
||||||
|
giveAllButton = addSelectableButton(firstRow, Strings.get("cheat_give_all", currentLanguage),
|
||||||
|
v -> nativeRequestCheat(CHEAT_GIVE_ALL));
|
||||||
|
physicsButton = addSelectableButton(firstRow, Strings.get("cheat_physics", currentLanguage),
|
||||||
|
v -> {
|
||||||
|
nativeRequestCheat(CHEAT_PHYSICS);
|
||||||
|
refreshCheatsSelection();
|
||||||
|
});
|
||||||
|
tab.addView(firstRow);
|
||||||
|
|
||||||
|
LinearLayout secondRow = new LinearLayout(activity);
|
||||||
|
secondRow.setOrientation(LinearLayout.HORIZONTAL);
|
||||||
|
levelUpButton = addSelectableButton(secondRow, Strings.get("cheat_level_up", currentLanguage),
|
||||||
|
v -> nativeRequestCheat(CHEAT_LEVEL_UP));
|
||||||
|
levelDownButton = addSelectableButton(secondRow, Strings.get("cheat_level_down", currentLanguage),
|
||||||
|
v -> nativeRequestCheat(CHEAT_LEVEL_DOWN));
|
||||||
|
tab.addView(secondRow);
|
||||||
|
|
||||||
|
LinearLayout thirdRow = new LinearLayout(activity);
|
||||||
|
thirdRow.setOrientation(LinearLayout.HORIZONTAL);
|
||||||
|
unlimitedHealthButton = addSelectableButton(thirdRow,
|
||||||
|
Strings.get("cheat_unlimited_health", currentLanguage), v -> toggleUnlimited(CHEAT_UNLIMITED_HEALTH));
|
||||||
|
unlimitedEnergyButton = addSelectableButton(thirdRow,
|
||||||
|
Strings.get("cheat_unlimited_energy", currentLanguage), v -> toggleUnlimited(CHEAT_UNLIMITED_ENERGY));
|
||||||
|
tab.addView(thirdRow);
|
||||||
|
|
||||||
|
LinearLayout fourthRow = new LinearLayout(activity);
|
||||||
|
fourthRow.setOrientation(LinearLayout.HORIZONTAL);
|
||||||
|
unlimitedAmmoButton = addSelectableButton(fourthRow,
|
||||||
|
Strings.get("cheat_unlimited_ammo", currentLanguage), v -> toggleUnlimited(CHEAT_UNLIMITED_AMMO));
|
||||||
|
unlimitedItemsButton = addSelectableButton(fourthRow,
|
||||||
|
Strings.get("cheat_unlimited_items", currentLanguage), v -> toggleUnlimited(CHEAT_UNLIMITED_ITEMS));
|
||||||
|
tab.addView(fourthRow);
|
||||||
|
|
||||||
|
return tab;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The requests run on the engine's thread a frame later, so the physics
|
||||||
|
// and unlimited highlights catch up on the next periodic refresh.
|
||||||
|
private void refreshCheatsSelection() {
|
||||||
|
physicsButton.setSelected(nativeGetCheatPhysicsRunning());
|
||||||
|
int mask = nativeGetCheatUnlimitedMask();
|
||||||
|
unlimitedHealthButton.setSelected((mask & 1) != 0);
|
||||||
|
unlimitedEnergyButton.setSelected((mask & 2) != 0);
|
||||||
|
unlimitedAmmoButton.setSelected((mask & 4) != 0);
|
||||||
|
unlimitedItemsButton.setSelected((mask & 8) != 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void toggleUnlimited(int cheatId) {
|
||||||
|
nativeRequestCheat(cheatId);
|
||||||
|
refreshCheatsSelection();
|
||||||
|
}
|
||||||
|
|
||||||
private View buildTabBar() {
|
private View buildTabBar() {
|
||||||
LinearLayout tabBar = new LinearLayout(activity);
|
LinearLayout tabBar = new LinearLayout(activity);
|
||||||
tabBar.setOrientation(LinearLayout.HORIZONTAL);
|
tabBar.setOrientation(LinearLayout.HORIZONTAL);
|
||||||
@@ -969,7 +1066,21 @@ public class MenuOverlay extends OverlayPanel {
|
|||||||
controllerOptionsTabButton.setOnTouchListener(CLICK_ON_TOUCH_UP);
|
controllerOptionsTabButton.setOnTouchListener(CLICK_ON_TOUCH_UP);
|
||||||
registerHoverable(controllerOptionsTabButton);
|
registerHoverable(controllerOptionsTabButton);
|
||||||
tabBar.addView(controllerOptionsTabButton,
|
tabBar.addView(controllerOptionsTabButton,
|
||||||
new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f));
|
new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1.3f));
|
||||||
|
|
||||||
|
View separator3 = new View(activity);
|
||||||
|
separator3.setBackgroundColor(0xFFFFFFFF);
|
||||||
|
tabBar.addView(separator3, new LinearLayout.LayoutParams(2, ViewGroup.LayoutParams.MATCH_PARENT));
|
||||||
|
|
||||||
|
cheatsTabButton = new Button(activity);
|
||||||
|
cheatsTabButton.setText(Strings.get("cheats", currentLanguage));
|
||||||
|
cheatsTabButton.setBackground(selectableButtonBackground());
|
||||||
|
cheatsTabButton.setTextColor(buttonTextColors());
|
||||||
|
cheatsTabButton.setOnClickListener(v -> switchTab(cheatsTab));
|
||||||
|
cheatsTabButton.setOnTouchListener(CLICK_ON_TOUCH_UP);
|
||||||
|
registerHoverable(cheatsTabButton);
|
||||||
|
tabBar.addView(cheatsTabButton,
|
||||||
|
new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 0.7f));
|
||||||
|
|
||||||
return tabBar;
|
return tabBar;
|
||||||
}
|
}
|
||||||
@@ -992,6 +1103,7 @@ public class MenuOverlay extends OverlayPanel {
|
|||||||
mainMenuTabButton.setSelected(tab == mainMenuTab);
|
mainMenuTabButton.setSelected(tab == mainMenuTab);
|
||||||
videoOptionsTabButton.setSelected(tab == videoOptionsTab);
|
videoOptionsTabButton.setSelected(tab == videoOptionsTab);
|
||||||
controllerOptionsTabButton.setSelected(tab == controllerOptionsTab);
|
controllerOptionsTabButton.setSelected(tab == controllerOptionsTab);
|
||||||
|
cheatsTabButton.setSelected(tab == cheatsTab);
|
||||||
nativeSetControllerOptionsTabOpen(tab == controllerOptionsTab);
|
nativeSetControllerOptionsTabOpen(tab == controllerOptionsTab);
|
||||||
resetHover();
|
resetHover();
|
||||||
if (tab == videoOptionsTab) {
|
if (tab == videoOptionsTab) {
|
||||||
@@ -1007,6 +1119,8 @@ public class MenuOverlay extends OverlayPanel {
|
|||||||
controllerScrollView.scrollTo(0, 0);
|
controllerScrollView.scrollTo(0, 0);
|
||||||
controllerScrollBar.setFraction(0f);
|
controllerScrollBar.setFraction(0f);
|
||||||
refreshControllerOptionsSelection();
|
refreshControllerOptionsSelection();
|
||||||
|
} else if (tab == cheatsTab) {
|
||||||
|
refreshCheatsSelection();
|
||||||
}
|
}
|
||||||
relayoutAndRedraw();
|
relayoutAndRedraw();
|
||||||
}
|
}
|
||||||
@@ -1172,6 +1286,8 @@ public class MenuOverlay extends OverlayPanel {
|
|||||||
refreshLanguageSelection();
|
refreshLanguageSelection();
|
||||||
} else if (currentTab == controllerOptionsTab) {
|
} else if (currentTab == controllerOptionsTab) {
|
||||||
refreshControllerOptionsSelection();
|
refreshControllerOptionsSelection();
|
||||||
|
} else if (currentTab == cheatsTab) {
|
||||||
|
refreshCheatsSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
// The stats text's width changes every tick (digit count, "ms"
|
// The stats text's width changes every tick (digit count, "ms"
|
||||||
@@ -1227,6 +1343,16 @@ public class MenuOverlay extends OverlayPanel {
|
|||||||
gameScreenDistanceLabel.setText(Strings.get("distance", currentLanguage));
|
gameScreenDistanceLabel.setText(Strings.get("distance", currentLanguage));
|
||||||
|
|
||||||
controllerOptionsTabButton.setText(Strings.get("controller_options", currentLanguage));
|
controllerOptionsTabButton.setText(Strings.get("controller_options", currentLanguage));
|
||||||
|
cheatsTabButton.setText(Strings.get("cheats", currentLanguage));
|
||||||
|
cheatsNoteView.setText(Strings.get("cheats_note", currentLanguage));
|
||||||
|
giveAllButton.setText(Strings.get("cheat_give_all", currentLanguage));
|
||||||
|
physicsButton.setText(Strings.get("cheat_physics", currentLanguage));
|
||||||
|
levelUpButton.setText(Strings.get("cheat_level_up", currentLanguage));
|
||||||
|
levelDownButton.setText(Strings.get("cheat_level_down", currentLanguage));
|
||||||
|
unlimitedHealthButton.setText(Strings.get("cheat_unlimited_health", currentLanguage));
|
||||||
|
unlimitedEnergyButton.setText(Strings.get("cheat_unlimited_energy", currentLanguage));
|
||||||
|
unlimitedAmmoButton.setText(Strings.get("cheat_unlimited_ammo", currentLanguage));
|
||||||
|
unlimitedItemsButton.setText(Strings.get("cheat_unlimited_items", currentLanguage));
|
||||||
controllerGenericNoteView.setText(Strings.get("generic_profile_note", currentLanguage));
|
controllerGenericNoteView.setText(Strings.get("generic_profile_note", currentLanguage));
|
||||||
controlResetButton.setText(Strings.get("reset_to_defaults", currentLanguage));
|
controlResetButton.setText(Strings.get("reset_to_defaults", currentLanguage));
|
||||||
for (int i = 0; i < ACTION_KEYS.length; i++) {
|
for (int i = 0; i < ACTION_KEYS.length; i++) {
|
||||||
@@ -1351,6 +1477,12 @@ public class MenuOverlay extends OverlayPanel {
|
|||||||
// or reassigned - see switchTab().
|
// or reassigned - see switchTab().
|
||||||
private static native void nativeSetControllerOptionsTabOpen(boolean open);
|
private static native void nativeSetControllerOptionsTabOpen(boolean open);
|
||||||
|
|
||||||
|
// Cheats tab - see qs_config.h. cheatId: 0 give all, 1 toggle physics, 2 next level,
|
||||||
|
// 3 previous level, 4-7 toggle unlimited health/energy/ammo/items.
|
||||||
|
private static native void nativeRequestCheat(int cheatId);
|
||||||
|
private static native int nativeGetCheatUnlimitedMask();
|
||||||
|
private static native boolean nativeGetCheatPhysicsRunning();
|
||||||
|
|
||||||
// Called from xr_overlay.c's xr_overlay_init_jni(), on the render
|
// Called from xr_overlay.c's xr_overlay_init_jni(), on the render
|
||||||
// thread, right after it resolves this class via the activity's own
|
// thread, right after it resolves this class via the activity's own
|
||||||
// ClassLoader (plain FindClass() can't see app classes from a thread
|
// ClassLoader (plain FindClass() can't see app classes from a thread
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import android.app.AlertDialog;
|
|||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.pm.PackageManager;
|
import android.content.pm.PackageManager;
|
||||||
import android.content.res.AssetManager;
|
import android.content.res.AssetManager;
|
||||||
|
import android.graphics.Bitmap;
|
||||||
|
import android.graphics.BitmapFactory;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
import android.view.SurfaceHolder;
|
import android.view.SurfaceHolder;
|
||||||
@@ -18,7 +20,7 @@ import org.libsdl.app.SDLActivity;
|
|||||||
import org.libsdl.app.SDLSurface;
|
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
|
* file I/O ("res/data/...", confirmed via grep - none of it goes through
|
||||||
* SDL_RWops, so Android's asset-manager fallback for SDL_RWFromFile
|
* SDL_RWops, so Android's asset-manager fallback for SDL_RWFromFile
|
||||||
* doesn't apply here) expects to find it - see android/engine-patches/ for
|
* doesn't apply here) expects to find it - see android/engine-patches/ for
|
||||||
@@ -40,7 +42,7 @@ public class QuestShockActivity extends SDLActivity {
|
|||||||
private static final String TAG = "QuestShock";
|
private static final String TAG = "QuestShock";
|
||||||
private static final int PERMISSION_REQUEST_STORAGE = 1;
|
private static final int PERMISSION_REQUEST_STORAGE = 1;
|
||||||
// Package-visible: MenuOverlay reads controller_profiles/ from here.
|
// Package-visible: MenuOverlay reads controller_profiles/ from here.
|
||||||
static final String GAME_DIR = "/sdcard/questshock";
|
static final String GAME_DIR = "/sdcard/QuestShock";
|
||||||
|
|
||||||
// Loaded here (rather than waiting for SDLActivity's own
|
// Loaded here (rather than waiting for SDLActivity's own
|
||||||
// getLibraries()/loadLibraries(), which only runs from super.onCreate())
|
// getLibraries()/loadLibraries(), which only runs from super.onCreate())
|
||||||
@@ -58,6 +60,9 @@ public class QuestShockActivity extends SDLActivity {
|
|||||||
// has no public chdir() (confirmed against the actual API 34 stub jar).
|
// has no public chdir() (confirmed against the actual API 34 stub jar).
|
||||||
private static native void nativeChdir(String path);
|
private static native void nativeChdir(String path);
|
||||||
|
|
||||||
|
// Straight-alpha ARGB pixels of the startup splash (qs_splash.c).
|
||||||
|
private static native void nativeSetSplashImage(int[] pixels, int width, int height);
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected String[] getLibraries() {
|
protected String[] getLibraries() {
|
||||||
return new String[] {
|
return new String[] {
|
||||||
@@ -187,11 +192,31 @@ public class QuestShockActivity extends SDLActivity {
|
|||||||
// before clearing mBrokenLibraries below so the engine can never
|
// before clearing mBrokenLibraries below so the engine can never
|
||||||
// start pre-chdir.
|
// start pre-chdir.
|
||||||
nativeChdir(GAME_DIR);
|
nativeChdir(GAME_DIR);
|
||||||
|
loadSplashImage();
|
||||||
|
|
||||||
// Assets confirmed present - safe to let the native engine start now.
|
// Assets confirmed present - safe to let the native engine start now.
|
||||||
SDLActivity.mBrokenLibraries = false;
|
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) {
|
private static boolean isNonEmptyDir(File dir) {
|
||||||
String[] entries = dir.list();
|
String[] entries = dir.list();
|
||||||
return entries != null && entries.length > 0;
|
return entries != null && entries.length > 0;
|
||||||
@@ -219,7 +244,7 @@ public class QuestShockActivity extends SDLActivity {
|
|||||||
+ "merging or extraction required.\n\n"
|
+ "merging or extraction required.\n\n"
|
||||||
+ "Then relaunch.\n\n"
|
+ "Then relaunch.\n\n"
|
||||||
+ "(These instructions are also in GET_ASSETS_QUEST.txt in the "
|
+ "(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)
|
.setCancelable(false)
|
||||||
.setPositiveButton("Exit", (dialog, which) -> finish())
|
.setPositiveButton("Exit", (dialog, which) -> finish())
|
||||||
.create()
|
.create()
|
||||||
|
|||||||
@@ -119,6 +119,19 @@ final class Strings {
|
|||||||
put("action_mfd_right_4", "Right MFD: Target", "MFD droit : cible", "Rechtes MFD: Ziel");
|
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_mfd_right_5", "Right MFD: Info", "MFD droit : info", "Rechtes MFD: Info");
|
||||||
put("action_fire_weapon", "Fire Weapon", "Tirer", "Waffe abfeuern");
|
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)",
|
put("action_free_left_thumbstick_view", "Free View (Left Stick)",
|
||||||
"Vue libre (stick gauche)", "Freie Ansicht (linker Stick)");
|
"Vue libre (stick gauche)", "Freie Ansicht (linker Stick)");
|
||||||
put("action_free_right_thumbstick_view", "Free View (Right Stick)",
|
put("action_free_right_thumbstick_view", "Free View (Right Stick)",
|
||||||
|
|||||||
|
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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">System Shock</string>
|
<string name="app_name">QuestShock</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -24,16 +24,33 @@ VERSION="$("$REPO_ROOT/build-image/version.sh")"
|
|||||||
VERSION_CODE="$(git -C "$REPO_ROOT" rev-list --count HEAD)"
|
VERSION_CODE="$(git -C "$REPO_ROOT" rev-list --count HEAD)"
|
||||||
echo "Version: $VERSION (versionCode $VERSION_CODE)"
|
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"
|
cd "$ANDROID_DIR"
|
||||||
./gradlew --no-daemon assembleDebug \
|
./gradlew --no-daemon "$GRADLE_TASK" \
|
||||||
"-PquestshockVersionName=$VERSION" "-PquestshockVersionCode=$VERSION_CODE"
|
"-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 $GRADLE_TASK" >&2; exit 1; }
|
||||||
[ -f "$APK" ] || { echo "BUILD FAIL: $APK not found after assembleDebug" >&2; exit 1; }
|
|
||||||
|
|
||||||
mkdir -p "$REPO_ROOT/dist"
|
mkdir -p "$REPO_ROOT/dist"
|
||||||
PKG_NAME="questshock-$VERSION-android-arm64.apk"
|
PKG_NAME="questshock-$VERSION-android-arm64$PKG_SUFFIX.apk"
|
||||||
cp "$APK" "$REPO_ROOT/dist/$PKG_NAME"
|
cp "$APK" "$REPO_ROOT/dist/$PKG_NAME"
|
||||||
|
|
||||||
echo "== Done =="
|
echo "== Done =="
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ 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/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 "$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 -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 =="
|
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
|
# 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,
|
Using SideQuest (or any MTP file browser) with your Quest connected,
|
||||||
copy its res/data/ and res/sound/ folders directly into:
|
copy its res/data/ and res/sound/ folders directly into:
|
||||||
/sdcard/questshock/res/data/
|
/sdcard/QuestShock/res/data/
|
||||||
/sdcard/questshock/res/sound/
|
/sdcard/QuestShock/res/sound/
|
||||||
They already contain everything needed, in the right layout - no
|
They already contain everything needed, in the right layout - no
|
||||||
merging or extraction required.
|
merging or extraction required.
|
||||||
|
|
||||||
|
|||||||
|
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 |
|
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 |
|
Before Width: | Height: | Size: 283 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 |
|
After Width: | Height: | Size: 166 KiB |