6 Commits

Author SHA1 Message Date
ml 135e474a14 Add v0.5.3 screenshots and a video; name captures by version.
build / build (push) Successful in 2m52s
2026-09-21 20:14:51 +02:00
ml 295f4869c1 Sign release APKs with a fixed key from CI secrets
build / build (push) Successful in 2m48s
2026-09-20 17:47:43 +02:00
ml f0a83df667 Replace the logo and icon with QuestShock artwork; add a GitHub release workflow
build / build (push) Successful in 2m14s
2026-09-20 17:23:55 +02:00
ml 9586c58a67 Add a QuestShock startup splash; rename the app and data folder
build / build (push) Successful in 2m35s
2026-09-20 16:49:28 +02:00
ml 5d7564c3b5 Update the docs and add AGENTS.md
build / build (push) Successful in 2m18s
2026-09-20 15:25:33 +02:00
ml 70c43240bc Add a Cheats menu tab with engine cheat buttons and unlimited-resource switches
build / build (push) Successful in 3m42s
The new Cheats tab exposes the engine's own cheat handlers as buttons:
give all items and weapons, toggle physics, next level and previous
level. Physics shows its state through the button highlight. The buttons
need a running game and show a message otherwise.

It also has four independent session-only switches for unlimited health,
energy, ammo and items (grenades and drugs). While a switch is on, its
resource is topped up every frame by qs_cheat_refill() from the new
engine patch 26. The switches are not saved to the config.
2026-09-20 14:59:33 +02:00
70 changed files with 1324 additions and 49 deletions
+18
View File
@@ -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.
+145
View File
@@ -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
+4
View File
@@ -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
+395
View File
@@ -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`.
+11 -3
View File
@@ -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:
+157 -22
View File
@@ -6,6 +6,17 @@ An open source project to play the classic 1994 System Shock on a VR
headset, built on top of [Shockolate](https://github.com/Interrupt/systemshock), 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.
## 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 +42,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 +69,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 +127,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 +217,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 +274,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 +288,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 +344,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 +465,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 +648,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/`:
+22 -2
View File
@@ -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
}
} }
} }
+9 -7
View File
@@ -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>
+86 -1
View File
@@ -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;
+15 -1
View File
@@ -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
+110
View File
@@ -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);
}
+15
View File
@@ -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();
}
+1
View File
@@ -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++) {
+5
View File
@@ -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()) -
+6
View File
@@ -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)",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 16 KiB

+1 -1
View File
@@ -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");
+22 -5
View File
@@ -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 =="
+1
View File
@@ -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
+2 -2
View File
@@ -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.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 445 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 423 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 KiB

Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB