4 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
65 changed files with 526 additions and 45 deletions
+18
View File
@@ -69,7 +69,25 @@ jobs:
dist/shockolate-*-linux-*.tar.gz
dist/shockolate-*-windows-*.zip
# The release key is stored base64-encoded in ANDROID_KEYSTORE_BASE64
# (see README section 4.3). Without it the APK is a debug build.
- name: Prepare release signing
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: |
set -euo pipefail
if [ -z "$KEYSTORE_BASE64" ]; then
echo "No signing key: the APK will be a debug build"
exit 0
fi
echo "$KEYSTORE_BASE64" | base64 -d > "${RUNNER_TEMP:-/tmp}/questshock-release.jks"
echo "QUESTSHOCK_KEYSTORE=${RUNNER_TEMP:-/tmp}/questshock-release.jks" >> "$GITHUB_ENV"
- name: Build Quest APK
env:
QUESTSHOCK_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
QUESTSHOCK_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
QUESTSHOCK_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
# QUESTSHOCK_BUILD_IMAGE is already set (see Preflight above), so
# this runs build-apk.sh directly instead of trying to docker-run
# the image again - same reasoning as `make package` above.
+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)
/registry.env
# Signing keys
*.jks
*.keystore
+34 -12
View File
@@ -16,7 +16,7 @@ a cross-platform port of the original engine, in an Android app that:
- 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`.
- 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.
@@ -34,7 +34,7 @@ These come from the maintainer. Follow them without being asked again.
`make apk` and `make android-studio` overwrite `android/engine.properties`
with container-only paths and break an open Android Studio project. What you
can do is dry-run patches (section 6) and run static consistency checks
(section 12). Say plainly in your report that the change was not built or
(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
@@ -74,7 +74,8 @@ These come from the maintainer. Follow them without being asked again.
| `android/app/build.gradle` | Gradle project; the `stageEngine` task stages the patched engine. |
| `android/force-rebuild.sh` | Stops Gradle daemons and wipes staged/build output for a fully fresh native build. |
| `build-image/` | Docker build environment (`Dockerfile`, `VERSION`, `prepare-android-project.sh`, `build-apk.sh`, engine build scripts). |
| `res/` | Bundled resources: `questshock.config.default.json`, `controller_profiles/` (JSON + PNG per controller), `assets/` (where the user's own game data goes, git-ignored), `run.sh`/`run.bat`. |
| `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. |
@@ -94,13 +95,14 @@ not print it or commit it.
| `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. |
| `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. |
@@ -116,6 +118,10 @@ not print it or commit it.
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
@@ -135,7 +141,7 @@ not print it or commit it.
`qs_config_frame_tick()`) is called from `xr_input.c`.
- JNI symbol names must match the Java `native` declarations exactly
(`Java_de_ladkau_questshock_<Class>_<method>`). A mismatch only fails at
runtime, so check it (section 12).
runtime, so check it (section 13).
### 4.5 Off-screen views (`OverlayPanel`, `MenuOverlay`, `KeyboardOverlay`)
@@ -314,20 +320,36 @@ toggles.
## 10. Bundled files on the device
`QuestShockActivity` extracts these into `/sdcard/questshock/`:
`QuestShockActivity` extracts these into `/sdcard/QuestShock/`:
- shaders, the default soundfont and `GET_ASSETS_QUEST.txt` (once);
- `questshock.config.default.json` and `controller_profiles/` (every launch,
overwriting only the bundled filenames so user-added profiles survive).
`res/controller_profiles/` and the default config are staged into APK assets by
`build-image/prepare-android-project.sh`; new staged directories must also be
declared as `inputs.dir(...)` of `stageEngine` in `android/app/build.gradle`.
`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. Code and documentation style
## 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
@@ -343,7 +365,7 @@ committed. The app refuses to start the engine until it is present.
- English, French and German strings live in `Strings.java`; keep the three
entries of each key on the same `put(...)`.
## 12. Verifying a change without building
## 13. Verifying a change without building
Because you may not build (rule 2), check statically and say so in the report:
@@ -365,7 +387,7 @@ Then report what was checked, what was not (build, on-device behaviour), and
any device check the maintainer should do (for example a new patch that may
need `android/force-rebuild.sh`).
## 13. Commit messages
## 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
+2 -2
View File
@@ -3,7 +3,7 @@
QuestShock can load a persistent, human-editable configuration file at
startup, in addition to (and independent of) the engine's own `prefs.txt`.
- **Location**: `/sdcard/questshock/questshock.config.json` - the same
- **Location**: `/sdcard/QuestShock/questshock.config.json` - the same
folder you already copy `res/data`/`res/sound` into (see README.md
section 5.1).
- **Format**: plain JSON, grouped by concern. Each top-level key is a
@@ -421,7 +421,7 @@ profile is active.
The Controller Options tab shows a labeled illustration of whichever
controller is actually active, driven by `res/controller_profiles/*.json`
(bundled with the app, copied to `/sdcard/questshock/controller_profiles/`
(bundled with the app, copied to `/sdcard/QuestShock/controller_profiles/`
on every launch, refreshing the known bundled files without touching any
extra ones you add there yourself). Each file:
+67 -10
View File
@@ -45,7 +45,12 @@ sideloaded Android app, and needs your own copy of the game data.
- `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.
[CONFIG.md](CONFIG.md)). Both are copied into the APK, as is
`res/splash.png`, the startup splash image. `res/logo.png` is the 1024x1024
logo, the master of the launcher icon
(`android/app/src/main/res/mipmap-*/ic_launcher.png` are scaled copies of it)
and the image to use for store listings; `res/logo_small.png` is the copy
shown at the top of this file.
- `Makefile` - targets:
- `all` (default) - alias for `dist`.
- `build-image` - builds the Docker build-image (`./build-image.sh`).
@@ -64,9 +69,11 @@ sideloaded Android app, and needs your own copy of the game data.
proprietary game assets (ships `res/GET_ASSETS.txt` in their place).
See "4.1. Packaging a distributable build" below.
- `apk` - builds `dist/questshock-<version>-android-arm64.apk` for
Android-based OpenXR headsets. Unlike every other target, this needs
network access at build time (Gradle/AGP's own dependency
resolution). See "5. Android / Quest build" below.
Android-based OpenXR headsets (a debug build, `...-arm64-debug.apk`,
when there is no signing key - see "4.3. Releases and signing" below).
Unlike every other target, this needs network access at build time
(Gradle/AGP's own dependency resolution). See "5. Android / Quest
build" below.
- `android-studio` - stages `engine/`/`android/gl4es-src/` (scratch,
patched copies) and the Android prebuilts with host-resolvable paths,
so Android Studio can compile and deploy `android/` natively instead
@@ -210,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
resulting tarball to dl.ladkau.de.
### 4.3. Releases and signing
The repository is developed on Gitea, and the GitHub repository is a mirror of
it. `.github/workflows/release.yml` is GitHub's own workflow (the Gitea build
is `.gitea/workflows/build.yml`):
- Pushing a tag `vX.Y.Z` builds the Linux and Windows packages and the Quest
APK inside the build-image and attaches them to a GitHub release with the
same name. A manual run ("Run workflow") builds the same files without
releasing them and keeps them as a downloadable workflow artifact.
- The build-image is pushed to GitHub's container registry (`ghcr.io`) when it
does not exist there yet. Its tag is a hash of `build-image/Dockerfile`,
`docker-entrypoint.sh` and `build-engine.sh`, so a change to any of them
builds a new image (`build-image/VERSION` is not used). The first run is slow
because it builds the image.
- The APK is signed with the release key described below.
**Signing.** Android only updates an installed app from an APK signed with the
same key, so every distributed APK is signed with one fixed key. `make apk`
(`build-image/build-apk.sh`) builds and signs the release APK when
`QUESTSHOCK_KEYSTORE` is set, together with `QUESTSHOCK_KEYSTORE_PASSWORD` and
optionally `QUESTSHOCK_KEY_ALIAS` (default `questshock`) and
`QUESTSHOCK_KEY_PASSWORD` (default: the keystore password). Without a keystore
it builds a debug APK, `questshock-<version>-android-arm64-debug.apk`, signed
with a per-machine debug key. That is what Android Studio's Run uses too; an
installed build has to be uninstalled before one signed with another key can be
installed.
Create the key once and keep the file and its password safe: without them,
users have to uninstall the app to get an update.
```sh
keytool -genkeypair -v -storetype PKCS12 -keystore questshock-release.jks \
-alias questshock -keyalg RSA -keysize 4096 -validity 10000
base64 -w0 questshock-release.jks # the value of ANDROID_KEYSTORE_BASE64
```
Both CI systems read the key from these secrets: `ANDROID_KEYSTORE_BASE64`,
`ANDROID_KEYSTORE_PASSWORD`, and optionally `ANDROID_KEY_ALIAS` and
`ANDROID_KEY_PASSWORD`. Without `ANDROID_KEYSTORE_BASE64`, the Gitea build
produces a debug APK and the GitHub build fails on a tag. `*.jks` and
`*.keystore` are git-ignored.
## 5. Android / Quest build
**Prerequisites:**
@@ -224,7 +274,8 @@ resulting tarball to dl.ladkau.de.
apk` needs its own separate toolchain - see "5.2. Building natively in
Android Studio" below.
`make apk` builds `dist/questshock-<version>-android-arm64.apk` - an
`make apk` builds `dist/questshock-<version>-android-arm64.apk` (or
`...-arm64-debug.apk`, see "4.3. Releases and signing" above) - an
immersive OpenXR app (see `android/app/src/main/cpp/xr_session.c`) that
can be sideloaded onto any Android-based VR headset with OpenXR support
(Meta Quest, Pico, etc. - see "1. Design principles" above), not just one
@@ -293,17 +344,17 @@ debugging enabled.)
1. Install the APK with [SideQuest](https://sidequestvr.com/) (or `adb
install`).
2. Launch it once. It'll ask for storage permission, then create
`/sdcard/questshock/` and extract its own bundled files (shaders, a
`/sdcard/QuestShock/` and extract its own bundled files (shaders, a
default MIDI soundfont) there - `res/data/` and `res/sound/` are
deliberately left missing, since that's the proprietary game data.
3. With the headset connected to a PC, use SideQuest's file browser (or
any MTP file manager) to copy your own `res/data/` and `res/sound/`
(see `/sdcard/questshock/GET_ASSETS_QUEST.txt`, extracted in step 2,
(see `/sdcard/QuestShock/GET_ASSETS_QUEST.txt`, extracted in step 2,
for exactly what's needed and where it comes from) into
`/sdcard/questshock/res/`.
`/sdcard/QuestShock/res/`.
4. Launch it again.
`/sdcard/questshock/` also holds an optional persistent settings file,
`/sdcard/QuestShock/` also holds an optional persistent settings file,
`questshock.config.json`, loaded automatically at startup and saved/loaded
from the in-headset menu - see [CONFIG.md](CONFIG.md) for the documented
options. Every launch also refreshes `questshock.config.default.json` (the
@@ -314,6 +365,9 @@ 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
@@ -411,7 +465,7 @@ the same as any other Android Studio project:
Android Studio builds, installs, and launches the app on the headset
directly. No manual `adb install` or SideQuest step needed for this
loop.
4. `/sdcard/questshock/` (game assets, extracted shaders/soundfont)
4. `/sdcard/QuestShock/` (game assets, extracted shaders/soundfont)
persists across reinstalls from Android Studio, so this loop doesn't
require re-copying `res/data/`/`res/sound/` on every iteration - only
a full uninstall or wiping that directory clears it.
@@ -611,6 +665,9 @@ each one does:
- **`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/`:
+22 -2
View File
@@ -107,6 +107,7 @@ def stageEngineTask = tasks.register("stageEngine", Exec) {
// Staged into assets/ by prepare-android-project.sh.
inputs.file("${repoRoot}/res/questshock.config.default.json")
inputs.dir("${repoRoot}/res/controller_profiles")
inputs.file("${repoRoot}/res/splash.png")
outputs.dir("${repoRoot}/build/android-engine")
outputs.dir("${repoRoot}/build/android-gl4es")
outputs.dir("${repoRoot}/build/android-prebuilt")
@@ -138,7 +139,7 @@ android {
// 29, not compileSdk's 34, is deliberate - see build-image/Dockerfile's
// comment on ANDROID_PLATFORM_VERSION: paired with
// requestLegacyExternalStorage in the manifest, this keeps
// /sdcard/questshock/ a plain, unrestricted shared folder instead of
// /sdcard/QuestShock/ a plain, unrestricted shared folder instead of
// Android 11+ scoped storage, on the Quest 2/3/3S/Pro headsets this
// targets - not just whatever's the current API level today.
minSdkVersion 24
@@ -199,7 +200,7 @@ android {
"-DCMAKE_FIND_ROOT_PATH_MODE_LIBRARY=BOTH", \
"-DCMAKE_FIND_ROOT_PATH_MODE_INCLUDE=BOTH", \
"-DCMAKE_SHARED_LINKER_FLAGS=-Wl,-z,max-page-size=16384", \
"-DANDROID_EXTRA_SOURCES=${projectDir}/src/main/cpp/questshock_native.c;${projectDir}/src/main/cpp/xr_session.c;${projectDir}/src/main/cpp/xr_input.c;${projectDir}/src/main/cpp/xr_overlay.c;${projectDir}/src/main/cpp/xr_swapchain.c;${projectDir}/src/main/cpp/xr_mouse.c;${projectDir}/src/main/cpp/qs_config.c"
"-DANDROID_EXTRA_SOURCES=${projectDir}/src/main/cpp/questshock_native.c;${projectDir}/src/main/cpp/xr_session.c;${projectDir}/src/main/cpp/xr_input.c;${projectDir}/src/main/cpp/xr_overlay.c;${projectDir}/src/main/cpp/xr_swapchain.c;${projectDir}/src/main/cpp/xr_mouse.c;${projectDir}/src/main/cpp/qs_config.c;${projectDir}/src/main/cpp/qs_splash.c"
abiFilters 'arm64-v8a'
}
}
@@ -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 {
release {
minifyEnabled false
if (releaseKeystore) {
signingConfig signingConfigs.release
}
}
}
+9 -7
View File
@@ -38,6 +38,11 @@
OpenXR session to actually start. -->
<meta-data android:name="com.oculus.vr.focusaware" android:value="true" />
<!-- Meta-specific: the headsets this app is built for. Other OpenXR
runtimes ignore it. -->
<meta-data android:name="com.oculus.supportedDevices"
android:value="quest2|questpro|quest3|quest3s" />
<activity android:name="de.ladkau.questshock.QuestShockActivity"
android:label="@string/app_name"
android:alwaysRetainTaskState="true"
@@ -51,13 +56,10 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<!-- The vendor-neutral, Khronos-defined way to declare an
immersive OpenXR entry point (vs. e.g. Meta's own
com.oculus.intent.category.VR) - see this project's
"favor OpenXR" rule in the README. If Horizon OS still
launches this as a flat panel on-device, the next thing
to try is adding a com.oculus.supportedDevices meta-data
listing the target Quest models - not confirmed
necessary from static analysis alone. -->
immersive OpenXR entry point, plus Meta's own VR
category, which Horizon OS looks for to treat the app as
a VR app. Other runtimes ignore the Meta one. -->
<category android:name="com.oculus.intent.category.VR" />
<category android:name="org.khronos.openxr.intent.category.IMMERSIVE_HMD" />
</intent-filter>
<intent-filter>
+1 -1
View File
@@ -10,7 +10,7 @@ extern "C" {
// Persistent, human-editable JSON config file - see CONFIG.md at the repo
// root for the documented schema. Lives at a plain relative path
// ("questshock.config.json") since the whole engine already runs with its
// cwd chdir()'d to the user-facing /sdcard/questshock/ folder (see
// cwd chdir()'d to the user-facing /sdcard/QuestShock/ folder (see
// QuestShockActivity.java's nativeChdir()) before main() ever runs - the
// same way prefs.txt/keybinds.txt (Prefs.c) already do.
//
+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 "qs_config.h"
#include "qs_splash.h"
#include "xr_input.h"
#include "xr_overlay.h"
#include "xr_session.h"
@@ -22,6 +23,15 @@ Java_de_ladkau_questshock_QuestShockActivity_nativeChdir(JNIEnv *env, jclass cla
(*env)->ReleaseStringUTFChars(env, path, cpath);
}
JNIEXPORT void JNICALL Java_de_ladkau_questshock_QuestShockActivity_nativeSetSplashImage(
JNIEnv *env, jclass clazz, jintArray pixels, jint width, jint height) {
jint *data = (*env)->GetIntArrayElements(env, pixels, NULL);
if (data == NULL)
return;
qs_splash_set_image((const uint32_t *)data, width, height);
(*env)->ReleaseIntArrayElements(env, pixels, data, JNI_ABORT);
}
// KeyboardOverlay's hand-built on-screen keyboard (see
// KeyboardOverlay.handleCharKeyDown()/handleCharKeyUp()) has no real
// Android IME session behind it, so printable characters can't reach the
+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) {
// Not g_game_swapchain.width/height: that's the fixed physical size of
// 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.
void xr_get_game_resolution(int *width, int *height);
// Pixel size of the game quad's swapchain image, fixed at launch (0, 0 before
// xr_init()). The quad's aspect ratio is this size, so full-quad content
// drawn outside the engine's own frame path (the startup splash) is laid out
// against it.
void xr_get_game_swapchain_size(int *width, int *height);
// Exponentially-smoothed frame rate/frame time, updated once per real
// rendered frame in xr_frame_end() - read by MenuOverlay's header (see
// questshock_native.c's nativeGetFps()/nativeGetFrameTimeMs()). 0 before
@@ -5,6 +5,8 @@ import android.app.AlertDialog;
import android.content.Context;
import android.content.pm.PackageManager;
import android.content.res.AssetManager;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.os.Bundle;
import android.util.Log;
import android.view.SurfaceHolder;
@@ -18,7 +20,7 @@ import org.libsdl.app.SDLActivity;
import org.libsdl.app.SDLSurface;
/**
* Prepares /sdcard/questshock/ the way Shockolate's plain relative-path
* Prepares /sdcard/QuestShock/ the way Shockolate's plain relative-path
* file I/O ("res/data/...", confirmed via grep - none of it goes through
* SDL_RWops, so Android's asset-manager fallback for SDL_RWFromFile
* doesn't apply here) expects to find it - see android/engine-patches/ for
@@ -40,7 +42,7 @@ public class QuestShockActivity extends SDLActivity {
private static final String TAG = "QuestShock";
private static final int PERMISSION_REQUEST_STORAGE = 1;
// 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
// 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).
private static native void nativeChdir(String path);
// Straight-alpha ARGB pixels of the startup splash (qs_splash.c).
private static native void nativeSetSplashImage(int[] pixels, int width, int height);
@Override
protected String[] getLibraries() {
return new String[] {
@@ -187,11 +192,31 @@ public class QuestShockActivity extends SDLActivity {
// before clearing mBrokenLibraries below so the engine can never
// start pre-chdir.
nativeChdir(GAME_DIR);
loadSplashImage();
// Assets confirmed present - safe to let the native engine start now.
SDLActivity.mBrokenLibraries = false;
}
// The engine shows this at startup (qs_splash.c); without it there is no splash.
private void loadSplashImage() {
try (InputStream in = getAssets().open("splash.png")) {
Bitmap bitmap = BitmapFactory.decodeStream(in);
if (bitmap == null) {
Log.e(TAG, "Could not decode splash.png");
return;
}
int width = bitmap.getWidth();
int height = bitmap.getHeight();
int[] pixels = new int[width * height];
bitmap.getPixels(pixels, 0, width, 0, 0, width, height);
bitmap.recycle();
nativeSetSplashImage(pixels, width, height);
} catch (IOException e) {
Log.e(TAG, "Could not read splash.png", e);
}
}
private static boolean isNonEmptyDir(File dir) {
String[] entries = dir.list();
return entries != null && entries.length > 0;
@@ -219,7 +244,7 @@ public class QuestShockActivity extends SDLActivity {
+ "merging or extraction required.\n\n"
+ "Then relaunch.\n\n"
+ "(These instructions are also in GET_ASSETS_QUEST.txt in the "
+ "questshock folder on this device, if you need to read them again.)")
+ "QuestShock folder on this device, if you need to read them again.)")
.setCancelable(false)
.setPositiveButton("Exit", (dialog, which) -> finish())
.create()
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"?>
<resources>
<string name="app_name">System Shock</string>
<string name="app_name">QuestShock</string>
</resources>
@@ -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)"
echo "Version: $VERSION (versionCode $VERSION_CODE)"
echo "== Building the APK (gradlew assembleDebug) =="
# A release APK is built and signed when QUESTSHOCK_KEYSTORE is set (with
# QUESTSHOCK_KEYSTORE_PASSWORD, and optionally QUESTSHOCK_KEY_ALIAS,
# default "questshock", and QUESTSHOCK_KEY_PASSWORD, default the keystore
# password). Without it the APK is a debug build, signed with a per-machine
# debug key.
if [ -n "${QUESTSHOCK_KEYSTORE:-}" ]; then
[ -f "$QUESTSHOCK_KEYSTORE" ] || { echo "BUILD FAIL: QUESTSHOCK_KEYSTORE ($QUESTSHOCK_KEYSTORE) does not exist" >&2; exit 1; }
[ -n "${QUESTSHOCK_KEYSTORE_PASSWORD:-}" ] || { echo "BUILD FAIL: QUESTSHOCK_KEYSTORE_PASSWORD is not set" >&2; exit 1; }
GRADLE_TASK=assembleRelease
APK="$ANDROID_DIR/app/build/outputs/apk/release/app-release.apk"
PKG_SUFFIX=""
else
echo "== QUESTSHOCK_KEYSTORE is not set: building a debug APK, not for distribution =="
GRADLE_TASK=assembleDebug
APK="$ANDROID_DIR/app/build/outputs/apk/debug/app-debug.apk"
PKG_SUFFIX="-debug"
fi
echo "== Building the APK (gradlew $GRADLE_TASK) =="
cd "$ANDROID_DIR"
./gradlew --no-daemon assembleDebug \
./gradlew --no-daemon "$GRADLE_TASK" \
"-PquestshockVersionName=$VERSION" "-PquestshockVersionCode=$VERSION_CODE"
APK="$ANDROID_DIR/app/build/outputs/apk/debug/app-debug.apk"
[ -f "$APK" ] || { echo "BUILD FAIL: $APK not found after assembleDebug" >&2; exit 1; }
[ -f "$APK" ] || { echo "BUILD FAIL: $APK not found after $GRADLE_TASK" >&2; exit 1; }
mkdir -p "$REPO_ROOT/dist"
PKG_NAME="questshock-$VERSION-android-arm64.apk"
PKG_NAME="questshock-$VERSION-android-arm64$PKG_SUFFIX.apk"
cp "$APK" "$REPO_ROOT/dist/$PKG_NAME"
echo "== Done =="
+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/questshock.config.default.json" "$ASSETS_DIR/questshock.config.default.json"
cp -a "$REPO_ROOT/res/controller_profiles" "$ASSETS_DIR/controller_profiles"
cp "$REPO_ROOT/res/splash.png" "$ASSETS_DIR/splash.png"
echo "== Staging prebuilt Android SDL2/SDL2_mixer/fluidsynth-lite/openxr .so's into jniLibs =="
# gl4es is deliberately absent here - it's no longer prebuilt, it's compiled
+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,
copy its res/data/ and res/sound/ folders directly into:
/sdcard/questshock/res/data/
/sdcard/questshock/res/sound/
/sdcard/QuestShock/res/data/
/sdcard/QuestShock/res/sound/
They already contain everything needed, in the right layout - no
merging or extraction required.
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