Compare commits
33 Commits
v0.3.0
...
ab2b778f44
| Author | SHA1 | Date | |
|---|---|---|---|
| ab2b778f44 | |||
| 389abd7de7 | |||
| c0952898ae | |||
| e410d82680 | |||
| c91080aba7 | |||
| cdb960dc71 | |||
| 790b4ffd21 | |||
| 135e474a14 | |||
| 295f4869c1 | |||
| f0a83df667 | |||
| 9586c58a67 | |||
| 5d7564c3b5 | |||
| 70c43240bc | |||
| d8324d409e | |||
| c24b7f15cb | |||
| e1e6a006d4 | |||
| 5fe4cdf7f8 | |||
| 369555efc7 | |||
| 360a1f7e3d | |||
| c0619a55ab | |||
| 09280dd410 | |||
| c4fd4eb3dd | |||
| e0a2e1f4bf | |||
| 504ed3e1cb | |||
| d6e438a541 | |||
| a2fb95e57b | |||
| 581af95e7b | |||
| 824b4a2506 | |||
| 920842a465 | |||
| 3be310ade6 | |||
| 2d8fb49bf5 | |||
| 1be0bebda2 | |||
| 2b52b818e8 |
@@ -0,0 +1,2 @@
|
|||||||
|
* text=auto
|
||||||
|
*.sh text eol=lf
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
name: build
|
name: build
|
||||||
|
|
||||||
# Builds a versioned Linux release tarball (see `make package`) on every
|
# Builds versioned Linux and Windows release packages (see `make
|
||||||
# push/PR, plus on-demand via the Gitea "Run workflow" button. Runs
|
# package`, which builds both - a .tar.gz and a .zip) on every push/PR,
|
||||||
|
# plus on-demand via the Gitea "Run workflow" button. Runs
|
||||||
# inside the build-image (see ../../build-image/Dockerfile, built/pushed
|
# inside the build-image (see ../../build-image/Dockerfile, built/pushed
|
||||||
# via ../../build-image.sh and ../../upload-image.sh), which bundles
|
# via ../../build-image.sh and ../../upload-image.sh), which bundles
|
||||||
# every dependency engine/ needs to compile - no network access needed
|
# every dependency engine/ needs to compile (for both platforms,
|
||||||
# at job runtime. Since the job's container already *is* the build-image
|
# including the Windows cross-toolchain) - no network access needed at
|
||||||
# (QUESTSHOCK_BUILD_IMAGE=1), `make package`'s `engine` prerequisite
|
# job runtime. Since the job's container already *is* the build-image
|
||||||
# compiles directly instead of trying to docker-run it again, which
|
# (QUESTSHOCK_BUILD_IMAGE=1), `make package`'s `engine`/`engine-win`
|
||||||
# wouldn't work here (no nested docker).
|
# prerequisites compile directly instead of trying to docker-run it
|
||||||
|
# again, which wouldn't work here (no nested docker).
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
pull_request:
|
pull_request:
|
||||||
@@ -50,19 +52,42 @@ jobs:
|
|||||||
# comment) - openxr was added to this image in the same
|
# comment) - openxr was added to this image in the same
|
||||||
# Android/Quest layer, so it's an equally good marker of that.
|
# Android/Quest layer, so it's an equally good marker of that.
|
||||||
[ -d /opt/prebuilt/android/openxr ] || { echo "PREFLIGHT FAIL: /opt/prebuilt/android/openxr missing - runner is using a build-image older than the Android/Quest layer" >&2; exit 1; }
|
[ -d /opt/prebuilt/android/openxr ] || { echo "PREFLIGHT FAIL: /opt/prebuilt/android/openxr missing - runner is using a build-image older than the Android/Quest layer" >&2; exit 1; }
|
||||||
|
[ -d /opt/prebuilt/win/sdl2 ] || { echo "PREFLIGHT FAIL: /opt/prebuilt/win/sdl2 missing - runner is using a build-image older than the Windows cross-build layer" >&2; exit 1; }
|
||||||
|
command -v x86_64-w64-mingw32-gcc >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: x86_64-w64-mingw32-gcc not in PATH" >&2; exit 1; }
|
||||||
|
command -v zip >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: zip not in PATH (needed by make package-win)" >&2; exit 1; }
|
||||||
|
|
||||||
- name: Build package
|
- name: Build package
|
||||||
run: make package
|
run: make package
|
||||||
|
|
||||||
- name: Upload build artifact
|
- name: Upload build artifacts
|
||||||
# v4 uses the newer @actions/artifact backend, which this Gitea
|
# v4 uses the newer @actions/artifact backend, which this Gitea
|
||||||
# instance's artifact storage doesn't support (GHESNotSupportedError) - v3 works.
|
# instance's artifact storage doesn't support (GHESNotSupportedError) - v3 works.
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: shockolate
|
name: shockolate
|
||||||
path: dist/shockolate-*-linux-*.tar.gz
|
path: |
|
||||||
|
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
|
- 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.
|
||||||
@@ -78,13 +103,14 @@ jobs:
|
|||||||
path: dist/questshock-*-android-*.apk
|
path: dist/questshock-*-android-*.apk
|
||||||
|
|
||||||
- name: Publish to dl.ladkau.de
|
- name: Publish to dl.ladkau.de
|
||||||
# Uploads the tarball and APK over SFTP instead of using
|
# Uploads the tarball, zip, and APK over SFTP instead of using
|
||||||
# actions/upload-artifact (whose zip wrapping can't be disabled).
|
# actions/upload-artifact (whose zip wrapping can't be disabled).
|
||||||
# Only runs on push so PR builds don't publish.
|
# Only runs on push so PR builds don't publish.
|
||||||
if: gitea.event_name == 'push'
|
if: gitea.event_name == 'push'
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
TARBALL="$(ls dist/shockolate-*-linux-*.tar.gz)"
|
TARBALL="$(ls dist/shockolate-*-linux-*.tar.gz)"
|
||||||
|
ZIP="$(ls dist/shockolate-*-windows-*.zip)"
|
||||||
APK="$(ls dist/questshock-*-android-*.apk)"
|
APK="$(ls dist/questshock-*-android-*.apk)"
|
||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
echo "${{ secrets.DL_SFTP_KEY }}" > ~/.ssh/dl_sftp_key
|
echo "${{ secrets.DL_SFTP_KEY }}" > ~/.ssh/dl_sftp_key
|
||||||
@@ -94,5 +120,6 @@ jobs:
|
|||||||
uploader@dl.ladkau.de <<EOF
|
uploader@dl.ladkau.de <<EOF
|
||||||
-mkdir files/questshock
|
-mkdir files/questshock
|
||||||
put $TARBALL files/questshock/$(basename "$TARBALL")
|
put $TARBALL files/questshock/$(basename "$TARBALL")
|
||||||
|
put $ZIP files/questshock/$(basename "$ZIP")
|
||||||
put $APK files/questshock/$(basename "$APK")
|
put $APK files/questshock/$(basename "$APK")
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -3,16 +3,22 @@
|
|||||||
|
|
||||||
# Game assets are not included in the repo
|
# Game assets are not included in the repo
|
||||||
/res/assets/setup_system_shock_enhanced*
|
/res/assets/setup_system_shock_enhanced*
|
||||||
|
/res/assets/setup_system_shock_classic*
|
||||||
|
/res/assets/*.zip
|
||||||
/res/assets/ss_ee/
|
/res/assets/ss_ee/
|
||||||
|
|
||||||
# Build output
|
# Build output
|
||||||
/dist/
|
/dist/
|
||||||
|
/dist-win/
|
||||||
/build/
|
/build/
|
||||||
|
|
||||||
# Engine build artifacts (engine/ is committed as a source snapshot; these
|
# Engine build artifacts (engine/ is committed as a source snapshot; these
|
||||||
# are generated by run-image.sh / build-image/build-engine.sh)
|
# are generated by run-image.sh / build-image/build-engine.sh /
|
||||||
|
# build-image/build-engine-win.sh - the Windows cross-build's own scratch
|
||||||
|
# copy of engine/ lives under /build/win-engine/, already covered above)
|
||||||
/engine/build_ext/
|
/engine/build_ext/
|
||||||
/engine/.build-output/
|
/engine/.build-output/
|
||||||
|
/engine/.build-output-win/
|
||||||
/engine/CMakeCache.txt
|
/engine/CMakeCache.txt
|
||||||
/engine/CMakeFiles/
|
/engine/CMakeFiles/
|
||||||
/engine/cmake_install.cmake
|
/engine/cmake_install.cmake
|
||||||
@@ -32,3 +38,12 @@
|
|||||||
|
|
||||||
# Local registry credentials (see registry.env.example)
|
# Local registry credentials (see registry.env.example)
|
||||||
/registry.env
|
/registry.env
|
||||||
|
|
||||||
|
# Signing keys
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
|
||||||
|
# modding/hacked build output and scratch, patched source copies (see
|
||||||
|
# modding/README.md)
|
||||||
|
/modding/dist/
|
||||||
|
/modding/build/
|
||||||
|
|||||||
@@ -0,0 +1,408 @@
|
|||||||
|
# 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/` or
|
||||||
|
`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/` (Quest-specific
|
||||||
|
fixes) or `engine-patches/` (general fixes, applied to the desktop
|
||||||
|
Linux/Windows builds too - README section 4.4), 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/`. |
|
||||||
|
| `engine-patches/` | Same, but general fixes applied to the desktop Linux/Windows builds too (README section 4.4), not Quest-specific ones. |
|
||||||
|
| `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), `store/` (store-listing artwork, generated from `logo.png`/`splash.png`/screenshots, not used by the app itself), `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. |
|
||||||
|
| `modding/` | Separate subproject: System Shock modding tools (InkyBlackness `hacked`, `ss-specs`), unrelated to the VR app. Own `README.md`, `Makefile` and Docker build-image; see that README rather than this 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
|
||||||
|
|
||||||
|
This section is written for `android/engine-patches/`; `engine-patches/`
|
||||||
|
(general fixes, also applied to the desktop Linux/Windows builds - README
|
||||||
|
section 4.4) follows the same process below except: no `#ifdef __ANDROID__`
|
||||||
|
guard (these aren't Android-specific), save as
|
||||||
|
`engine-patches/NN-desktop-<topic>.patch`, and there is no Gradle
|
||||||
|
`stageEngine` to worry about going stale - `build-image/build-engine.sh`/
|
||||||
|
`build-engine-win.sh` re-stage and re-patch on every build.
|
||||||
|
|
||||||
|
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`.
|
||||||
@@ -0,0 +1,458 @@
|
|||||||
|
# Persistent configuration (`questshock.config.json`)
|
||||||
|
|
||||||
|
QuestShock can load a persistent, human-editable configuration file at
|
||||||
|
startup, in addition to (and independent of) the engine's own `prefs.txt`.
|
||||||
|
|
||||||
|
- **Location**: `/sdcard/QuestShock/questshock.config.json` - the same
|
||||||
|
folder you already copy `res/data`/`res/sound` into (see README.md
|
||||||
|
section 5.1).
|
||||||
|
- **Format**: plain JSON, grouped by concern. Each top-level key is a
|
||||||
|
*group*; each key inside a group is one *option*.
|
||||||
|
- **Loading**: read automatically at app startup, before the splash screen,
|
||||||
|
if the file exists. If it doesn't, `questshock.config.default.json` (same
|
||||||
|
folder) is used instead - a copy bundled with the app, refreshed on every
|
||||||
|
launch, never overwriting your own saved `questshock.config.json`. If
|
||||||
|
neither file exists, the engine behaves as it did before this file
|
||||||
|
existed (driven by `prefs.txt` as usual).
|
||||||
|
- **Saving**: never automatic. In-headset, open the menu (controller menu
|
||||||
|
button) and press **Save Configuration** to write the file from your
|
||||||
|
current live settings, overwriting it if present. Press **Load
|
||||||
|
Configuration** to re-read the file and apply it without restarting the
|
||||||
|
app.
|
||||||
|
- **Live vs. restart-required**: every option below is tagged as either
|
||||||
|
*live-appliable* (applying it while already playing takes effect
|
||||||
|
immediately) or *restart-required*. Pressing **Load Configuration**
|
||||||
|
applies every live-appliable change straight away; if the file also
|
||||||
|
changes something restart-required, a dialog lists exactly which
|
||||||
|
option(s) need it and offers:
|
||||||
|
- **Dismiss** - keep playing with the live-appliable changes already
|
||||||
|
applied; the restart-required option(s) stay as they were.
|
||||||
|
- **Restart Now** - relaunches the app, which re-runs this same startup
|
||||||
|
load and so applies every group in the file, including the
|
||||||
|
restart-required ones.
|
||||||
|
|
||||||
|
(Every option currently documented below is live-appliable, so this
|
||||||
|
dialog won't actually appear yet - it's built in for options added in a
|
||||||
|
future version that do need a restart.)
|
||||||
|
- Unrecognized groups/keys are ignored (harmless if you're editing the file
|
||||||
|
in advance for a future version). An invalid value for a *specific*
|
||||||
|
option (see each option's possible values below) is logged and that one
|
||||||
|
option is skipped - every other valid option in the file still applies.
|
||||||
|
A file that isn't valid JSON at all is ignored entirely, with nothing
|
||||||
|
applied.
|
||||||
|
- `questshock.config.json` and `prefs.txt` are fully independent - loading
|
||||||
|
or saving one never reads or writes the other.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"video_options": {
|
||||||
|
"renderer": "opengl",
|
||||||
|
"resolution": "1024x768",
|
||||||
|
"initial_screen_width_m": 1.6,
|
||||||
|
"initial_screen_distance_m": 2.0,
|
||||||
|
"game_screen_width_m": 2.8,
|
||||||
|
"game_screen_distance_m": 2.0,
|
||||||
|
"screen_width_offset_m": 1.0,
|
||||||
|
"screen_width_step_m": 0.6,
|
||||||
|
"screen_distance_offset_m": 1.0,
|
||||||
|
"screen_distance_step_m": 0.5
|
||||||
|
},
|
||||||
|
"general": {
|
||||||
|
"language": "english"
|
||||||
|
},
|
||||||
|
"player_position": {
|
||||||
|
"x": 0.0,
|
||||||
|
"z": 0.0,
|
||||||
|
"yaw_degrees": 0.0
|
||||||
|
},
|
||||||
|
"player_controls": {
|
||||||
|
"move_forward": "left_thumbstick_up",
|
||||||
|
"move_back": "left_thumbstick_down",
|
||||||
|
"strafe_left": "left_thumbstick_left",
|
||||||
|
"strafe_right": "left_thumbstick_right",
|
||||||
|
"turn_left": "right_thumbstick_left",
|
||||||
|
"turn_right": "right_thumbstick_right",
|
||||||
|
"jump": "left_thumbstick_click",
|
||||||
|
"mouse_click": "right_a",
|
||||||
|
"mouse_double_click": "right_b",
|
||||||
|
"stand": "right_thumbstick_up",
|
||||||
|
"prone": "right_thumbstick_down",
|
||||||
|
"map_view": "right_thumbstick_click",
|
||||||
|
"fire_weapon": "right_trigger"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Video Options (`video_options`)
|
||||||
|
|
||||||
|
### `renderer`
|
||||||
|
|
||||||
|
Which renderer the game uses to draw the 3D view.
|
||||||
|
|
||||||
|
- **Type**: enum
|
||||||
|
- **Possible values**: `"software"`, `"opengl"`
|
||||||
|
- **Restart required?**: No (live-appliable)
|
||||||
|
|
||||||
|
### `resolution`
|
||||||
|
|
||||||
|
The game's internal rendering resolution.
|
||||||
|
|
||||||
|
- **Type**: enum
|
||||||
|
- **Possible values**: `"320x200"`, `"320x400"`, `"640x400"`, `"640x480"`, `"1024x768"`
|
||||||
|
- **Restart required?**: No (live-appliable)
|
||||||
|
|
||||||
|
### `initial_screen_width_m`, `initial_screen_distance_m`, `game_screen_width_m`, `game_screen_distance_m`
|
||||||
|
|
||||||
|
The virtual screen's physical width and distance from the player (both in
|
||||||
|
meters) - the game shows two independently configurable screen
|
||||||
|
"profiles", switched automatically based on what's currently on screen:
|
||||||
|
|
||||||
|
- **Initial Screen** (`initial_screen_*`) - the splash screen, intro
|
||||||
|
cutscenes, and the game's own main menu.
|
||||||
|
- **Game Screen** (`game_screen_*`) - actual gameplay (the 3D view,
|
||||||
|
automap, cyberspace, etc.).
|
||||||
|
|
||||||
|
Switching happens automatically - no player action needed - the instant
|
||||||
|
the game itself transitions between these (e.g. starting/loading a game,
|
||||||
|
or returning to the menu).
|
||||||
|
|
||||||
|
- **Type**: number (meters)
|
||||||
|
- **Possible values**: the in-headset Video Options tab offers 5 presets
|
||||||
|
each - widths `1.0`, `1.6` (the original, pre-this-feature default),
|
||||||
|
`2.2`, `2.8`, `3.4`; distances `1.0`, `1.5`, `2.0` (the original,
|
||||||
|
pre-this-feature default), `2.5`, `3.0` - but a hand-edited value
|
||||||
|
doesn't need to match a preset exactly (it's clamped to a sane range -
|
||||||
|
width `[0.2, 10.0]`, distance `[0.5, 10.0]` - rather than snapped or
|
||||||
|
rejected; the Video Options tab just shows no button selected for an
|
||||||
|
in-between value).
|
||||||
|
- **Restart required?**: No (live-appliable)
|
||||||
|
- **Note**: the screen is always flat - there's no curve option.
|
||||||
|
|
||||||
|
### `screen_width_offset_m`, `screen_width_step_m`, `screen_distance_offset_m`, `screen_distance_step_m`
|
||||||
|
|
||||||
|
Reshapes the 5 preset buttons the Video Options tab offers for width and
|
||||||
|
distance (above) - config-file-only, there's no in-headset control for
|
||||||
|
these. Shared by both **Initial Screen** and **Game Screen** (both
|
||||||
|
columns' buttons show the same 5 values per row) - this only changes
|
||||||
|
what the buttons offer, not either profile's current value.
|
||||||
|
|
||||||
|
Preset button `index` (0-4) shows `offset + step * index`. For example,
|
||||||
|
a distance offset of `0` and step of `2` shows buttons `0`, `2`, `4`,
|
||||||
|
`6`, `8`; the defaults below reproduce this feature's original launch
|
||||||
|
presets exactly.
|
||||||
|
|
||||||
|
- **Type**: number (meters)
|
||||||
|
- **Default values**: width offset `1.0`, step `0.6` (buttons `1.0`,
|
||||||
|
`1.6`, `2.2`, `2.8`, `3.4`); distance offset `1.0`, step `0.5` (buttons
|
||||||
|
`1.0`, `1.5`, `2.0`, `2.5`, `3.0`).
|
||||||
|
- **Restart required?**: No (live-appliable)
|
||||||
|
- **Note**: not clamped - an unusual offset/step (e.g. negative step) is
|
||||||
|
accepted as-is. A resulting preset value outside the current value's
|
||||||
|
own clamp range (see above) still clamps normally when that preset
|
||||||
|
button is actually pressed.
|
||||||
|
|
||||||
|
## General (`general`)
|
||||||
|
|
||||||
|
### `language`
|
||||||
|
|
||||||
|
The game's UI/text language - both the game's own resource strings and this
|
||||||
|
project's own menu/keyboard chrome text (title bar, buttons, etc.).
|
||||||
|
|
||||||
|
- **Type**: enum
|
||||||
|
- **Possible values**: `"english"`, `"french"`, `"german"`
|
||||||
|
- **Restart required?**: No (live-appliable) - applies the same way the
|
||||||
|
game's own in-game options menu applies a language change (reloads the
|
||||||
|
language-specific resource files and refreshes the inventory/MFD/side-icon
|
||||||
|
UI immediately).
|
||||||
|
- **Note**: French and German both need their own full resource pack
|
||||||
|
(`frnstrng.res`/`mfdfrn.res`, `gerstrng.res`/`mfdger.res` under
|
||||||
|
`res/data/`) - if any of those files aren't present, the switch is
|
||||||
|
refused (logged, `questshock.config.json` and the current language are
|
||||||
|
left unchanged) rather than leaving the game with no valid resource file
|
||||||
|
open. See README.md section 3 ("Game assets") for where `mfdfrn.res`/
|
||||||
|
`mfdger.res` come from - the GOG Enhanced Edition installer alone doesn't
|
||||||
|
include them.
|
||||||
|
- The intro cutscene is also played in the selected language when its
|
||||||
|
voice-over file (`svfrintr.res`/`svgeintr.res` under `res/data/`) is
|
||||||
|
present; otherwise the English intro plays.
|
||||||
|
|
||||||
|
## Player Position (`player_position`)
|
||||||
|
|
||||||
|
Where the player is virtually standing/facing, independent of - and not
|
||||||
|
reset by - starting a new game or loading a save (same independence from
|
||||||
|
`prefs.txt`/savegames as every other group here). Set by moving around
|
||||||
|
in-headset (menu open, left thumbstick snap-turns, right thumbstick smooth-
|
||||||
|
moves) to line yourself up with the game quad ("virtual screen"). Height is
|
||||||
|
never included here - it always comes from your real tracked headset
|
||||||
|
height, not this file.
|
||||||
|
|
||||||
|
**Startup fallback**: only applied at startup if all three fields (`x`,
|
||||||
|
`z`, `yaw_degrees`) are present - a config file with none of them (or none
|
||||||
|
at all), or a partial/hand-edited one missing any of the three, falls back
|
||||||
|
to an automatic recenter instead (see **Reset Position** below), using
|
||||||
|
your real head pose at that moment. A complete saved position is applied
|
||||||
|
as-is, with no such recenter.
|
||||||
|
|
||||||
|
**Reset Position** (Main Menu tab) is a live-only action, not a config
|
||||||
|
value: it moves you to the centre in front of the game quad, based on where
|
||||||
|
your head actually is right now. It resets only your *position*, not which
|
||||||
|
way you're facing, so the screen does not rotate to follow your gaze - press
|
||||||
|
it while looking off to one side and the screen stays off to the other side.
|
||||||
|
The automatic recenter at startup (when there's no saved position) does
|
||||||
|
face the screen towards where you're looking. Reset Position never reads or
|
||||||
|
writes `questshock.config.json`.
|
||||||
|
|
||||||
|
### `x`, `z`
|
||||||
|
|
||||||
|
Horizontal position, in meters, on the play space's floor plane.
|
||||||
|
|
||||||
|
- **Type**: number
|
||||||
|
- **Restart required?**: No (live-appliable)
|
||||||
|
|
||||||
|
### `yaw_degrees`
|
||||||
|
|
||||||
|
Which way the player is virtually facing, in degrees.
|
||||||
|
|
||||||
|
- **Type**: number
|
||||||
|
- **Restart required?**: No (live-appliable)
|
||||||
|
|
||||||
|
## Player Controls (`player_controls`)
|
||||||
|
|
||||||
|
Maps each of 57 in-game actions to a physical controller input: 9
|
||||||
|
movement/click actions, 2 free-view actions, 11 cyberspace actions, and 35
|
||||||
|
for the engine's own keyboard hotkeys and firing (posture, save/load, weapon
|
||||||
|
select, MFD panels, etc. - see the hotkey table below). Some actions are bound by default (see the default-input
|
||||||
|
columns); the rest are unbound and can be bound on demand. Assign them in the
|
||||||
|
**Controller Options** menu tab: tap an action's binding button, then press
|
||||||
|
the button or stick direction to use ("Press a button…"); tap it again, or
|
||||||
|
wait 8 seconds, to cancel without changing it.
|
||||||
|
|
||||||
|
- **Type**: one enum-string field per action (below)
|
||||||
|
- **Restart required?**: No (live-appliable) - a rebind takes effect
|
||||||
|
immediately, and persists across restarts once saved (**Save
|
||||||
|
Configuration**, same as every other group here).
|
||||||
|
- **Menu movement**: while the Controller Options tab is open, the menu's own
|
||||||
|
thumbstick snap-turn and smooth-move (see `player_position`) are disabled, so
|
||||||
|
pressing inputs to assign them doesn't move you away from the screen.
|
||||||
|
- **Not included here**: opening/closing the VR menu itself is a fixed,
|
||||||
|
non-remappable button (see "Menu button" below) - there's no
|
||||||
|
`menu_toggle` field, and the Controller Options tab never lets you
|
||||||
|
reassign it. This guarantees you can always reach this tab and press
|
||||||
|
**Reset to Defaults** to recover from any bad rebind, no matter what else
|
||||||
|
you've reassigned.
|
||||||
|
- **Reset to Defaults**: restores the "Default input" column below for
|
||||||
|
every action not overridden by a `player_controls` group in
|
||||||
|
`questshock.config.default.json` - if that file defines one (it doesn't,
|
||||||
|
out of the box), the values it specifies are applied on top of the table
|
||||||
|
below instead, so you can customize what "reset" means without a rebuild.
|
||||||
|
This is independent of your own `questshock.config.json` - resetting
|
||||||
|
doesn't read or write that file, just the live bindings (**Save
|
||||||
|
Configuration** afterward to persist them).
|
||||||
|
|
||||||
|
### Actions
|
||||||
|
|
||||||
|
| Action key | Default input | Effect |
|
||||||
|
|---|---|---|
|
||||||
|
| `move_forward` | `left_thumbstick_up` | Walk forward |
|
||||||
|
| `move_back` | `left_thumbstick_down` | Walk backward |
|
||||||
|
| `strafe_left` | `left_thumbstick_left` | Step sideways, left |
|
||||||
|
| `strafe_right` | `left_thumbstick_right` | Step sideways, right |
|
||||||
|
| `turn_left` | `right_thumbstick_left` | Turn left |
|
||||||
|
| `turn_right` | `right_thumbstick_right` | Turn right |
|
||||||
|
| `jump` | `left_thumbstick_click` | Jump |
|
||||||
|
| `mouse_click` | `right_a` | Click - operates the keyboard/game quad's cursor, whichever the pointing hand's ray is over |
|
||||||
|
| `mouse_double_click` | `right_b` | Double-click - game quad only (e.g. using an inventory item); has no effect over the menu or keyboard |
|
||||||
|
| `free_left_thumbstick_view` | `left_squeeze` | Hold to look around with the left thumbstick (see below) |
|
||||||
|
| `free_right_thumbstick_view` | `none` | Hold to look around with the right thumbstick (see below) |
|
||||||
|
|
||||||
|
Movement actions bound to a thumbstick direction are suppressed while the
|
||||||
|
VR menu is open, since the same physical stick already snap-turns/smooth-
|
||||||
|
moves the player in that state (see `player_position` above) - this is the
|
||||||
|
one case where a binding's effect depends on whether the menu is open, and
|
||||||
|
it only ever affects the 8 thumbstick-direction inputs below.
|
||||||
|
|
||||||
|
**Free view** (`free_left_thumbstick_view`, `free_right_thumbstick_view`) is
|
||||||
|
the engine's mouse look driven by a thumbstick. While the bound input is held,
|
||||||
|
that stick looks around instead of doing its normal job: left/right turns the
|
||||||
|
view, up/down looks up and down, in proportion to how far the stick is pushed.
|
||||||
|
The stick's own direction bindings (move, strafe, turn, stand/prone) are
|
||||||
|
suspended while it is held, and the cursor stays centred in the view. Release
|
||||||
|
the input to return to normal. It has no effect while the VR menu is open.
|
||||||
|
|
||||||
|
### Cyberspace actions
|
||||||
|
|
||||||
|
Cyberspace has its own set of movement actions, active only while the player
|
||||||
|
is in cyberspace. While in cyberspace the normal movement actions
|
||||||
|
(`move_*`, `strafe_*`, `turn_*`, `jump`, `stand`, `crouch`, `prone`) are
|
||||||
|
inactive, and the cyberspace actions are inactive everywhere else. Each set
|
||||||
|
has its own bindings, so one input can be bound in both (for example the left
|
||||||
|
stick up is `move_forward` on foot and `cyber_climb` in cyberspace);
|
||||||
|
the Controller Options tab only reports a conflict between actions that can
|
||||||
|
be active at the same time. Free view, clicking and firing work in both.
|
||||||
|
|
||||||
|
Pitch and bank (`cyber_climb`, `cyber_dive`, `cyber_bank_*` and the four
|
||||||
|
diagonals) are proportional when bound to a thumbstick direction: pushing the
|
||||||
|
stick further turns faster, from 20% of full speed just past the trigger point
|
||||||
|
to 60% at full deflection. Bound to a button they run at full speed. Thrust and
|
||||||
|
roll are always full speed.
|
||||||
|
|
||||||
|
| Action key | Default input | Keyboard equivalent | Effect |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `cyber_thrust` | `right_thumbstick_up` | `s` | Thrust forward |
|
||||||
|
| `cyber_climb` | `left_thumbstick_up` | `w` | Pitch up |
|
||||||
|
| `cyber_dive` | `left_thumbstick_down` | `x` | Pitch down |
|
||||||
|
| `cyber_bank_left` | `left_thumbstick_left` | `a` | Bank left |
|
||||||
|
| `cyber_bank_right` | `left_thumbstick_right` | `d` | Bank right |
|
||||||
|
| `cyber_roll_left` | `right_thumbstick_left` | `e` | Roll left |
|
||||||
|
| `cyber_roll_right` | `right_thumbstick_right` | `q` | Roll right |
|
||||||
|
| `cyber_climb_left` | `none` | Keypad 7 | Pitch up and bank left |
|
||||||
|
| `cyber_climb_right` | `none` | Keypad 9 | Pitch up and bank right |
|
||||||
|
| `cyber_dive_left` | `none` | Keypad 1 | Pitch down and bank left |
|
||||||
|
| `cyber_dive_right` | `none` | Keypad 3 | Pitch down and bank right |
|
||||||
|
|
||||||
|
`mouse_click` additionally behaves differently depending on the menu:
|
||||||
|
while the VR menu is open, its "click" is always whichever hand's trigger
|
||||||
|
is used to interact with the menu (so you can never lock yourself out of
|
||||||
|
this settings screen by rebinding `mouse_click` elsewhere); once the menu
|
||||||
|
is closed, both the on-screen keyboard (if it's still open) and the game
|
||||||
|
quad use your actual `mouse_click` binding. The default (`right_a`) is a
|
||||||
|
specific button on the right controller, so only the right hand's ray clicks;
|
||||||
|
bind the `trigger` identifier (see below) for either hand to click.
|
||||||
|
|
||||||
|
### Hotkey actions
|
||||||
|
|
||||||
|
The engine's own keyboard hotkeys (`HotKeyLookup[]` in `Prefs.c`) plus
|
||||||
|
firing, as 35 bindable actions. `stand` (`right_thumbstick_up`), `prone`
|
||||||
|
(`right_thumbstick_down`), `map_view` (`right_thumbstick_click`) and
|
||||||
|
`fire_weapon` (`right_trigger`) are bound by default; the rest are unbound
|
||||||
|
(`none`). Each fires once per press, like pressing the key on a real
|
||||||
|
keyboard (there is no hold behavior).
|
||||||
|
|
||||||
|
| Action key | Keyboard equivalent | Effect |
|
||||||
|
|---|---|---|
|
||||||
|
| `stand` | `t` | Stand up |
|
||||||
|
| `crouch` | `g` | Crouch |
|
||||||
|
| `prone` | `b` | Go prone |
|
||||||
|
| `pause` | `p` | Pause the game |
|
||||||
|
| `full_view` | Ctrl+F | Switch to the fullscreen view loop |
|
||||||
|
| `normal_view` | Ctrl+D | Switch to the normal gameplay view |
|
||||||
|
| `map_view` | Ctrl+A | Switch to the automap view |
|
||||||
|
| `save_game` | Ctrl+S | Save the game |
|
||||||
|
| `load_game` | Ctrl+L | Load the game |
|
||||||
|
| `select_grenade` | Ctrl+' | Select the next grenade type |
|
||||||
|
| `arm_grenade` | Alt+' | Arm the selected grenade |
|
||||||
|
| `select_drug` | Ctrl+; | Select the next drug/patch |
|
||||||
|
| `use_drug` | Alt+; | Use the selected drug/patch |
|
||||||
|
| `toggle_online_help` | Ctrl+H | Toggle the online help overlay |
|
||||||
|
| `show_help` | Alt+O | Show the help screen |
|
||||||
|
| `toggle_music` | Ctrl+M | Toggle music |
|
||||||
|
| `quit` | Ctrl+Q | Quit the game - opt-in only; nothing binds this by default |
|
||||||
|
| `cycle_weapon_next` | Tab | Ready the next weapon |
|
||||||
|
| `cycle_weapon_prev` | Shift+Tab | Ready the previous weapon |
|
||||||
|
| `cycle_detail` | Ctrl+1 | Cycle the graphics detail level |
|
||||||
|
| `reload_weapon_primary` | Ctrl+Backspace | Reload the current weapon's primary ammo |
|
||||||
|
| `reload_weapon_secondary` | Alt+Backspace | Reload the current weapon's secondary ammo |
|
||||||
|
| `hud_color` | Alt+H | Cycle the HUD color scheme |
|
||||||
|
| `clear_fullscreen` | Backspace | Close a fullscreen view (e.g. a datapad or email) |
|
||||||
|
| `mfd_left_1`-`mfd_left_5` | F1-F5 | Set the left MFD panel to Weapon/Item/Map/Target/Info |
|
||||||
|
| `mfd_right_1`-`mfd_right_5` | F6-F10 | Set the right MFD panel to Weapon/Item/Map/Target/Info |
|
||||||
|
| `fire_weapon` | Enter | Fire the readied weapon at the cursor (the engine's default fire key). Clicking in the 3D view with `mouse_click` also fires; this is a separate button for it |
|
||||||
|
|
||||||
|
**Not exposed** (other `HotKeyLookup[]` entries):
|
||||||
|
- `keypad 0`-`keypad 9`: a controller button per digit is not useful; use the
|
||||||
|
on-screen keyboard.
|
||||||
|
- `toggle_freelook` (F): the free-view actions cover mouse look.
|
||||||
|
- The 4 `cheat_*` entries: debug cheats. They are on the menu's Cheats tab
|
||||||
|
instead, along with four switches for unlimited health, energy, ammo and
|
||||||
|
items. Those are not saved to the config.
|
||||||
|
- `mac_help` (disabled) and `audiolog_cancel` (its build flag is not
|
||||||
|
defined): they are not real hotkeys in this build.
|
||||||
|
- `toggle_opengl` (Ctrl+G): the Video Options tab's Renderer buttons already
|
||||||
|
toggle the same setting.
|
||||||
|
- The 10 numeric hardware-item hotkeys (bio scan, fullscreen, 360 view,
|
||||||
|
lantern, shield, infrared, nav unit, data reader, booster, jumpjets): their
|
||||||
|
registered keys never match a real keypress in the engine.
|
||||||
|
|
||||||
|
### Raw input identifiers
|
||||||
|
|
||||||
|
Every raw physical input available to bind an action to, with its generic
|
||||||
|
OpenXR interaction-profile component name and its Meta Quest Touch control
|
||||||
|
name. Two symmetric identifiers (`trigger`, `squeeze`) are also accepted, but
|
||||||
|
only for `mouse_click`/`mouse_double_click`: they mean "either hand's own
|
||||||
|
trigger/grip, whichever hand is pointing". Every other identifier below
|
||||||
|
refers to one specific control.
|
||||||
|
|
||||||
|
| Identifier | OpenXR component (Touch) | Meta Quest control |
|
||||||
|
|---|---|---|
|
||||||
|
| `left_trigger` | `/user/hand/left/input/trigger/value` | Left Trigger |
|
||||||
|
| `right_trigger` | `/user/hand/right/input/trigger/value` | Right Trigger |
|
||||||
|
| `left_squeeze` | `/user/hand/left/input/squeeze/value` | Left Grip |
|
||||||
|
| `right_squeeze` | `/user/hand/right/input/squeeze/value` | Right Grip |
|
||||||
|
| `left_x` | `/user/hand/left/input/x/click` | X Button |
|
||||||
|
| `left_y` | `/user/hand/left/input/y/click` | Y Button |
|
||||||
|
| `right_a` | `/user/hand/right/input/a/click` | A Button |
|
||||||
|
| `right_b` | `/user/hand/right/input/b/click` | B Button |
|
||||||
|
| `left_thumbstick_click` | `/user/hand/left/input/thumbstick/click` | Left Thumbstick Click |
|
||||||
|
| `right_thumbstick_click` | `/user/hand/right/input/thumbstick/click` | Right Thumbstick Click |
|
||||||
|
| `left_thumbstick_up/down/left/right` | `/user/hand/left/input/thumbstick` (digitized) | Left Thumbstick, tilted |
|
||||||
|
| `right_thumbstick_up/down/left/right` | `/user/hand/right/input/thumbstick` (digitized) | Right Thumbstick, tilted |
|
||||||
|
| `trigger` | either hand's trigger | Either Trigger (`mouse_click`/`mouse_double_click` only) |
|
||||||
|
| `squeeze` | either hand's grip | Either Grip (`mouse_click`/`mouse_double_click` only) |
|
||||||
|
| `none` | - | Unbound - the action never fires |
|
||||||
|
|
||||||
|
**Menu button** (left controller, Touch/Vive/generic alike) is not in this
|
||||||
|
list - it always opens/closes the VR menu and can never be reassigned.
|
||||||
|
|
||||||
|
**Other supported controllers**: HTC Vive wands have no A/B/X/Y and no
|
||||||
|
analog grip - `left_x`/`left_y`/`right_a`/`right_b` simply never fire on
|
||||||
|
that profile, and `left/right_squeeze` are a plain click rather than
|
||||||
|
analog. Their trackpad stands in for the thumbstick identifiers above
|
||||||
|
(same digitization, just a trackpad instead of a stick). A bare-minimum
|
||||||
|
OpenXR controller (`khr/simple_controller`, used as a fallback when
|
||||||
|
neither Touch nor Vive bindings are suggested) only has `left_trigger`/
|
||||||
|
`right_trigger` (via its `select/click` component) and the menu button -
|
||||||
|
every other identifier above simply never fires on it, which is why the
|
||||||
|
Controller Options tab shows a note and hides irrelevant rows when this
|
||||||
|
profile is active.
|
||||||
|
|
||||||
|
### Controller profile files (`controller_profiles/`)
|
||||||
|
|
||||||
|
The Controller Options tab shows a labeled illustration of whichever
|
||||||
|
controller is actually active, driven by `res/controller_profiles/*.json`
|
||||||
|
(bundled with the app, copied to `/sdcard/QuestShock/controller_profiles/`
|
||||||
|
on every launch, refreshing the known bundled files without touching any
|
||||||
|
extra ones you add there yourself). Each file:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Meta Quest Touch",
|
||||||
|
"interaction_profile": "/interaction_profiles/oculus/touch_controller",
|
||||||
|
"image": "touch.png",
|
||||||
|
"controls": [
|
||||||
|
{"id": "right_b", "label": "B", "anchor_x": 0.76, "anchor_y": 0.22}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `interaction_profile` - the OpenXR interaction profile path this file
|
||||||
|
describes; matched against whichever one is actually bound at runtime.
|
||||||
|
Falls back to `generic.json` if nothing matches.
|
||||||
|
- `image` - filename (same folder) of the controller illustration.
|
||||||
|
- `controls` - one entry per labeled point on that illustration: `id` is a
|
||||||
|
raw input identifier from the table above (thumbstick directions share
|
||||||
|
one `left_thumbstick`/`right_thumbstick` entry, since they're the same
|
||||||
|
physical stick); `anchor_x`/`anchor_y` are 0-1 fractions of the image for
|
||||||
|
positioning that label's pointer line.
|
||||||
|
|
||||||
|
This is presentation data only - adding real support for a new controller
|
||||||
|
still needs matching bindings in the app itself; a profile file with no
|
||||||
|
corresponding code-level support just never gets matched.
|
||||||
|
|
||||||
|
### Fatal startup error
|
||||||
|
|
||||||
|
If the OpenXR runtime rejects any of the three supported controller
|
||||||
|
profiles' bindings at launch (an app bug, not something you did), a
|
||||||
|
non-cancelable dialog explains this and exits - this should never happen
|
||||||
|
in a normal build.
|
||||||
@@ -1,20 +1,28 @@
|
|||||||
# Assembles dist/ - a self-contained, runnable copy of System Shock -
|
# Assembles dist/ (Linux) and dist-win/ (Windows, cross-compiled via
|
||||||
# from the compiled engine (built via Docker, see build-image.sh/
|
# MinGW) - self-contained, runnable copies of System Shock - from the
|
||||||
# run-image.sh) and the game assets extracted from a purchased copy (see
|
# compiled engine (built via Docker, see build-image.sh/run-image.sh) and
|
||||||
|
# the game assets extracted from a purchased copy (see
|
||||||
# res/assets/extract_assets.sh). Building the engine needs the build-image
|
# res/assets/extract_assets.sh). Building the engine needs the build-image
|
||||||
# (./build-image.sh, once); everything else here is plain file copying.
|
# (./build-image.sh, once); everything else here is plain file copying.
|
||||||
|
# `dist-linux`/`dist-win` build just one platform; plain `dist` builds
|
||||||
|
# both.
|
||||||
#
|
#
|
||||||
# `make package` instead builds a redistributable tarball that omits the
|
# `make package` instead builds redistributable archives (a .tar.gz for
|
||||||
# proprietary game assets entirely (see res/assets/GET_ASSETS.txt, which
|
# Linux, a .zip for Windows) that omit the proprietary game assets
|
||||||
# it ships in their place) - this is what CI publishes.
|
# entirely (see res/assets/GET_ASSETS.txt, which they ship in their
|
||||||
|
# place) - this is what CI publishes. Same dist-linux/dist-win/(both)
|
||||||
|
# split via package-linux/package-win/package.
|
||||||
|
|
||||||
DIST_DIR := dist
|
DIST_DIR := dist
|
||||||
ENGINE_OUT := engine/.build-output
|
DIST_WIN_DIR := dist-win
|
||||||
ASSETS_DIR := res/assets/ss_ee
|
ENGINE_OUT := engine/.build-output
|
||||||
BUILD_DIR := build
|
ENGINE_OUT_WIN := engine/.build-output-win
|
||||||
ARCH := $(shell uname -m)
|
ASSETS_DIR := res/assets/ss_ee
|
||||||
|
BUILD_DIR := build
|
||||||
|
ARCH := $(shell uname -m)
|
||||||
|
|
||||||
.PHONY: all dist build-image engine assets package apk clean
|
.PHONY: all dist dist-linux dist-win build-image engine engine-win assets \
|
||||||
|
package package-linux package-win apk android-studio clean
|
||||||
|
|
||||||
all: dist
|
all: dist
|
||||||
|
|
||||||
@@ -40,6 +48,16 @@ engine:
|
|||||||
./run-image.sh; \
|
./run-image.sh; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Cross-compiles engine/ for Windows via MinGW - see build-engine-win.sh
|
||||||
|
# for why this can't share build-engine.sh's engine/build_ext/ (a scratch
|
||||||
|
# copy is used instead). Same QUESTSHOCK_BUILD_IMAGE detection as `engine`.
|
||||||
|
engine-win:
|
||||||
|
@if [ -n "$$QUESTSHOCK_BUILD_IMAGE" ]; then \
|
||||||
|
bash build-image/build-engine-win.sh; \
|
||||||
|
else \
|
||||||
|
./run-image.sh bash build-image/build-engine-win.sh; \
|
||||||
|
fi
|
||||||
|
|
||||||
# Fails with a pointer to extract_assets.sh if the purchased game assets
|
# Fails with a pointer to extract_assets.sh if the purchased game assets
|
||||||
# haven't been extracted yet.
|
# haven't been extracted yet.
|
||||||
assets:
|
assets:
|
||||||
@@ -49,10 +67,13 @@ assets:
|
|||||||
exit 1; \
|
exit 1; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
dist: engine assets
|
# Builds both platforms. `dist-linux`/`dist-win` build just one.
|
||||||
|
dist: dist-linux dist-win
|
||||||
|
|
||||||
|
dist-linux: engine assets
|
||||||
@echo "== Assembling $(DIST_DIR) =="
|
@echo "== Assembling $(DIST_DIR) =="
|
||||||
rm -rf "$(DIST_DIR)/systemshock" "$(DIST_DIR)/lib" "$(DIST_DIR)/res" \
|
rm -rf "$(DIST_DIR)/systemshock" "$(DIST_DIR)/lib" "$(DIST_DIR)/res" \
|
||||||
"$(DIST_DIR)/shaders" "$(DIST_DIR)/run.sh"
|
"$(DIST_DIR)/shaders" "$(DIST_DIR)/run.sh" "$(DIST_DIR)/run-mods.sh"
|
||||||
mkdir -p "$(DIST_DIR)/lib" "$(DIST_DIR)/res/data" "$(DIST_DIR)/res/sound"
|
mkdir -p "$(DIST_DIR)/lib" "$(DIST_DIR)/res/data" "$(DIST_DIR)/res/sound"
|
||||||
cp "$(ENGINE_OUT)/systemshock" "$(DIST_DIR)/"
|
cp "$(ENGINE_OUT)/systemshock" "$(DIST_DIR)/"
|
||||||
cp -a "$(ENGINE_OUT)/lib/." "$(DIST_DIR)/lib/"
|
cp -a "$(ENGINE_OUT)/lib/." "$(DIST_DIR)/lib/"
|
||||||
@@ -62,8 +83,32 @@ dist: engine assets
|
|||||||
cp -a "$(ASSETS_DIR)/sound/." "$(DIST_DIR)/res/sound/"
|
cp -a "$(ASSETS_DIR)/sound/." "$(DIST_DIR)/res/sound/"
|
||||||
cp res/run.sh "$(DIST_DIR)/run.sh"
|
cp res/run.sh "$(DIST_DIR)/run.sh"
|
||||||
chmod +x "$(DIST_DIR)/run.sh"
|
chmod +x "$(DIST_DIR)/run.sh"
|
||||||
|
cp res/run-mods.sh "$(DIST_DIR)/run-mods.sh"
|
||||||
|
chmod +x "$(DIST_DIR)/run-mods.sh"
|
||||||
@echo "== Done - run $(DIST_DIR)/run.sh to play =="
|
@echo "== Done - run $(DIST_DIR)/run.sh to play =="
|
||||||
|
|
||||||
|
# Windows counterpart of dist-linux. Unlike dist/, the DLLs sit flat
|
||||||
|
# alongside systemshock.exe instead of in a lib/ subdirectory - Windows'
|
||||||
|
# default DLL search order already checks the executable's own directory
|
||||||
|
# first, so (unlike run.sh's LD_LIBRARY_PATH) run.bat needs no extra
|
||||||
|
# wiring for that.
|
||||||
|
dist-win: engine-win assets
|
||||||
|
@echo "== Assembling $(DIST_WIN_DIR) =="
|
||||||
|
rm -rf "$(DIST_WIN_DIR)"
|
||||||
|
mkdir -p "$(DIST_WIN_DIR)/res/data" "$(DIST_WIN_DIR)/res/sound"
|
||||||
|
cp "$(ENGINE_OUT_WIN)/systemshock.exe" "$(ENGINE_OUT_WIN)"/*.dll "$(DIST_WIN_DIR)/"
|
||||||
|
cp "$(ENGINE_OUT_WIN)/soundfont.sf2" "$(DIST_WIN_DIR)/res/"
|
||||||
|
cp -a engine/shaders "$(DIST_WIN_DIR)/shaders"
|
||||||
|
cp -a "$(ASSETS_DIR)/data/." "$(DIST_WIN_DIR)/res/data/"
|
||||||
|
cp -a "$(ASSETS_DIR)/sound/." "$(DIST_WIN_DIR)/res/sound/"
|
||||||
|
cp res/run.bat "$(DIST_WIN_DIR)/run.bat"
|
||||||
|
cp res/run-mods.bat "$(DIST_WIN_DIR)/run-mods.bat"
|
||||||
|
@echo "== Done - run $(DIST_WIN_DIR)/run.bat (or systemshock.exe) to play =="
|
||||||
|
|
||||||
|
# Builds both platforms' redistributable archives. `package-linux`/
|
||||||
|
# `package-win` build just one.
|
||||||
|
package: package-linux package-win
|
||||||
|
|
||||||
# Builds a versioned, redistributable Linux release tarball at
|
# Builds a versioned, redistributable Linux release tarball at
|
||||||
# dist/shockolate-<version>-linux-<arch>.tar.gz - everything needed to
|
# dist/shockolate-<version>-linux-<arch>.tar.gz - everything needed to
|
||||||
# run except the proprietary game assets (res/GET_ASSETS.txt explains how
|
# run except the proprietary game assets (res/GET_ASSETS.txt explains how
|
||||||
@@ -72,7 +117,7 @@ dist: engine assets
|
|||||||
# tag to drive a release. Override with `make package VERSION=1.2.3`, or
|
# tag to drive a release. Override with `make package VERSION=1.2.3`, or
|
||||||
# just run it untagged for a local dev build (gets a 0.0.0-dev+<sha>
|
# just run it untagged for a local dev build (gets a 0.0.0-dev+<sha>
|
||||||
# placeholder version, with a warning).
|
# placeholder version, with a warning).
|
||||||
package: engine
|
package-linux: engine
|
||||||
@git config --global --add safe.directory "$$(pwd)" 2>/dev/null || true
|
@git config --global --add safe.directory "$$(pwd)" 2>/dev/null || true
|
||||||
@V="$$(VERSION="$(VERSION)" ./build-image/version.sh)"; \
|
@V="$$(VERSION="$(VERSION)" ./build-image/version.sh)"; \
|
||||||
PKG_NAME="shockolate-$$V-linux-$(ARCH)"; \
|
PKG_NAME="shockolate-$$V-linux-$(ARCH)"; \
|
||||||
@@ -95,6 +140,33 @@ package: engine
|
|||||||
rm -rf "$(BUILD_DIR)/package"; \
|
rm -rf "$(BUILD_DIR)/package"; \
|
||||||
echo "Wrote $(DIST_DIR)/$$PKG_NAME.tar.gz"
|
echo "Wrote $(DIST_DIR)/$$PKG_NAME.tar.gz"
|
||||||
|
|
||||||
|
# Windows counterpart of package-linux: dist/shockolate-<version>-windows-
|
||||||
|
# x86_64.zip. Same versioning (build-image/version.sh) and VERSION=
|
||||||
|
# override. Needs `zip` on whatever host runs `make package-win` itself
|
||||||
|
# (unlike engine-win's own build, packaging isn't run inside the
|
||||||
|
# build-image) - see README's Desktop build prerequisites.
|
||||||
|
package-win: engine-win
|
||||||
|
@git config --global --add safe.directory "$$(pwd)" 2>/dev/null || true
|
||||||
|
@command -v zip >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: zip not found in PATH" >&2; exit 1; }
|
||||||
|
@V="$$(VERSION="$(VERSION)" ./build-image/version.sh)"; \
|
||||||
|
PKG_NAME="shockolate-$$V-windows-x86_64"; \
|
||||||
|
PKG_STAGE="$(BUILD_DIR)/package/$$PKG_NAME"; \
|
||||||
|
echo "Packaging $$PKG_NAME"; \
|
||||||
|
rm -rf "$$PKG_STAGE"; \
|
||||||
|
mkdir -p "$$PKG_STAGE/res"; \
|
||||||
|
cp "$(ENGINE_OUT_WIN)/systemshock.exe" "$(ENGINE_OUT_WIN)"/*.dll "$$PKG_STAGE/"; \
|
||||||
|
cp -a engine/shaders "$$PKG_STAGE/shaders"; \
|
||||||
|
cp "$(ENGINE_OUT_WIN)/soundfont.sf2" "$$PKG_STAGE/res/"; \
|
||||||
|
cp res/assets/GET_ASSETS.txt "$$PKG_STAGE/res/GET_ASSETS.txt"; \
|
||||||
|
cp res/run.bat "$$PKG_STAGE/run.bat"; \
|
||||||
|
cp LICENSE "$$PKG_STAGE/LICENSE"; \
|
||||||
|
cp engine/LICENSE "$$PKG_STAGE/LICENSE.Shockolate"; \
|
||||||
|
cp NOTICE.txt "$$PKG_STAGE/NOTICE.txt"; \
|
||||||
|
mkdir -p "$(DIST_DIR)"; \
|
||||||
|
(cd "$(BUILD_DIR)/package" && zip -rq "$(CURDIR)/$(DIST_DIR)/$$PKG_NAME.zip" "$$PKG_NAME"); \
|
||||||
|
rm -rf "$(BUILD_DIR)/package"; \
|
||||||
|
echo "Wrote $(DIST_DIR)/$$PKG_NAME.zip"
|
||||||
|
|
||||||
# Builds the Quest APK (see build-image/build-apk.sh and
|
# Builds the Quest APK (see build-image/build-apk.sh and
|
||||||
# android/engine-patches/ - engine/ itself is never modified; a patch is
|
# android/engine-patches/ - engine/ itself is never modified; a patch is
|
||||||
# applied to a scratch copy at build time instead). Same
|
# applied to a scratch copy at build time instead). Same
|
||||||
@@ -108,8 +180,23 @@ apk:
|
|||||||
./run-image.sh bash build-image/build-apk.sh; \
|
./run-image.sh bash build-image/build-apk.sh; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Stages a scratch, patched copy of engine/ and android/gl4es-src/, plus
|
||||||
|
# the Android SDL2/SDL2_mixer/fluidsynth-lite/openxr prebuilts, with
|
||||||
|
# host-resolvable paths (writes android/engine.properties) so Android
|
||||||
|
# Studio can compile and deploy android/ natively - see README's "5.2.
|
||||||
|
# Building natively in Android Studio". `make apk` doesn't need this; it
|
||||||
|
# stages the same things itself inside the build-image. Re-run after
|
||||||
|
# `make clean` (which deletes android/engine.properties) or after
|
||||||
|
# changing engine/, android/engine-patches/, android/gl4es-src/,
|
||||||
|
# android/gl4es-patches/, or the prebuilt-library versions in
|
||||||
|
# build-image/Dockerfile - Android Studio's own Gradle build already
|
||||||
|
# does this automatically via its stageEngine task, so this is mainly
|
||||||
|
# useful for first-time setup or confirming staging succeeded on its own.
|
||||||
|
android-studio:
|
||||||
|
./run-image.sh bash build-image/prepare-android-project.sh --host-paths
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf "$(DIST_DIR)" "$(BUILD_DIR)" "$(ENGINE_OUT)" \
|
rm -rf "$(DIST_DIR)" "$(DIST_WIN_DIR)" "$(BUILD_DIR)" "$(ENGINE_OUT)" "$(ENGINE_OUT_WIN)" \
|
||||||
engine/build_ext engine/CMakeCache.txt engine/CMakeFiles \
|
engine/build_ext engine/CMakeCache.txt engine/CMakeFiles \
|
||||||
engine/cmake_install.cmake engine/Makefile engine/systemshock \
|
engine/cmake_install.cmake engine/Makefile engine/systemshock \
|
||||||
engine/src/Libraries/CMakeFiles \
|
engine/src/Libraries/CMakeFiles \
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ and is not relicensed by this project's MIT license.
|
|||||||
|
|
||||||
Game assets are not included - see res/GET_ASSETS.txt.
|
Game assets are not included - see res/GET_ASSETS.txt.
|
||||||
|
|
||||||
|
System Shock is a trademark of its respective owners. This is an unofficial
|
||||||
|
fan project and is not affiliated with, endorsed by or supported by them.
|
||||||
|
|
||||||
The Android build additionally bundles:
|
The Android build additionally bundles:
|
||||||
|
|
||||||
- GL4ES (https://github.com/ptitSeb/gl4es), vendored and patched (see
|
- GL4ES (https://github.com/ptitSeb/gl4es), vendored and patched (see
|
||||||
@@ -16,3 +19,33 @@ The Android build additionally bundles:
|
|||||||
- The Khronos Group's OpenXR-SDK loader
|
- The Khronos Group's OpenXR-SDK loader
|
||||||
(https://github.com/KhronosGroup/OpenXR-SDK), prebuilt unmodified as
|
(https://github.com/KhronosGroup/OpenXR-SDK), prebuilt unmodified as
|
||||||
lib/arm64-v8a/libopenxr_loader.so. It is Apache 2.0-licensed.
|
lib/arm64-v8a/libopenxr_loader.so. It is Apache 2.0-licensed.
|
||||||
|
|
||||||
|
The Windows build (cross-compiled via MinGW - see README's "4.2. Windows
|
||||||
|
cross-build") additionally bundles:
|
||||||
|
|
||||||
|
- GLEW (http://glew.sourceforge.net/), unmodified, as glew32.dll - used
|
||||||
|
for OpenGL extension loading, which Windows' own opengl32.dll doesn't
|
||||||
|
provide past OpenGL 1.1 (Linux instead gets this straight from Mesa's
|
||||||
|
headers, needing no separate loader library - see
|
||||||
|
engine/src/MacSrc/OpenGL.cc). It is licensed under a combination of the
|
||||||
|
Modified BSD License, the MIT License, and the Khronos License, all
|
||||||
|
permissive.
|
||||||
|
- The MinGW-w64 runtime's winpthreads library, as libwinpthread-1.dll. It
|
||||||
|
is MIT-licensed. (GCC's own runtime, libgcc/libstdc++, is linked
|
||||||
|
statically into systemshock.exe instead of shipped as a DLL, under the
|
||||||
|
GCC Runtime Library Exception - this doesn't subject the rest of the
|
||||||
|
binary to the GPL.)
|
||||||
|
|
||||||
|
The modding tools' `hacked` build (see modding/README.md; output under
|
||||||
|
modding/dist/*.tgz and *.zip) additionally bundles:
|
||||||
|
|
||||||
|
- InkyBlackness HackEd (https://github.com/inkyblackness/hacked), vendored
|
||||||
|
unmodified as modding/src/hacked/. It is New BSD-licensed.
|
||||||
|
- github.com/sqweek/dialog, vendored and patched (see
|
||||||
|
modding/src/sqweek-dialog-patches/) as modding/src/sqweek-dialog/. It is
|
||||||
|
ISC-licensed.
|
||||||
|
- hacked's further direct Go dependencies (fetched at build time, not
|
||||||
|
vendored in this repository): github.com/inkyblackness/imgui-go (New BSD);
|
||||||
|
github.com/go-gl/glfw, github.com/go-gl/mathgl, and golang.org/x/image
|
||||||
|
(BSD 3-Clause); github.com/go-gl/gl, github.com/asticode/go-astisub, and
|
||||||
|
github.com/lucasb-eyer/go-colorful (MIT).
|
||||||
|
|||||||
@@ -6,6 +6,56 @@ An open source project to play the classic 1994 System Shock on a VR
|
|||||||
headset, built on top of [Shockolate](https://github.com/Interrupt/systemshock),
|
headset, built on top of [Shockolate](https://github.com/Interrupt/systemshock),
|
||||||
a cross-platform port of the original game.
|
a cross-platform port of the original game.
|
||||||
|
|
||||||
|
The game is played on a virtual screen: the engine renders its usual flat 2D
|
||||||
|
frame, and Questshock shows it as a large screen floating in front of you in
|
||||||
|
VR. It is not a stereoscopic 3D conversion; you sit or stand in front of the
|
||||||
|
screen the way you would in front of a monitor or a cinema screen. The
|
||||||
|
screen's width and distance can be set separately for the menus/intro and for
|
||||||
|
gameplay. Both controllers act as laser pointers for the game's mouse-driven
|
||||||
|
interface and for a VR menu and on-screen keyboard, and every controller
|
||||||
|
button, stick direction and game hotkey can be assigned to a game action.
|
||||||
|
Questshock runs entirely on the headset (tested on a Meta Quest 2) as a
|
||||||
|
sideloaded Android app, and needs your own copy of the game data.
|
||||||
|
|
||||||
|
> **Unofficial fan project.** Questshock is not affiliated with, endorsed by or
|
||||||
|
> supported by the rights holders of System Shock. It contains no game data: you
|
||||||
|
> need your own copy of System Shock: Enhanced Edition.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td width="50%"><img src="res/screenshots/questshock_v0.5.3_001.jpg" alt="Exploring the station on the virtual screen"><br><sub>Exploring the station on the virtual screen</sub></td>
|
||||||
|
<td width="50%"><img src="res/screenshots/questshock_v0.5.3_003.jpg" alt="Reading an email - the laser pointer clicks like a mouse"><br><sub>Reading an email - the laser pointer clicks like a mouse</sub></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td width="50%"><img src="res/screenshots/questshock_v0.5.3_005.jpg" alt="Cyberspace"><br><sub>Cyberspace</sub></td>
|
||||||
|
<td width="50%"><img src="res/screenshots/questshock_v0.5.3_006.jpg" alt="The game screen, the menu and the keyboard float independently"><br><sub>The game screen, the menu and the keyboard float independently</sub></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td width="50%"><img src="res/screenshots/questshock_v0.5.3_007.jpg" alt="The VR menu"><br><sub>The VR menu</sub></td>
|
||||||
|
<td width="50%"><img src="res/screenshots/questshock_v0.5.3_008.jpg" alt="Assigning controller inputs to game actions"><br><sub>Assigning controller inputs to game actions</sub></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
[A short video](res/screenshots/questshock_v0.5.3_010.mp4) shows the game in use.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
You need a Meta Quest (tested on a Quest 2) and your own copy of the game data.
|
||||||
|
|
||||||
|
1. Get **System Shock: Enhanced Edition** (for example from
|
||||||
|
[gog.com](https://www.gog.com/)); see "3. Game assets" below.
|
||||||
|
2. Download `questshock-<version>-android-arm64.apk` from the repository's
|
||||||
|
Releases page and sideload it onto the headset with
|
||||||
|
[SideQuest](https://sidequestvr.com/) or `adb install`.
|
||||||
|
3. Launch it once. It creates `/sdcard/QuestShock/`. Copy your game's `res/data/`
|
||||||
|
and `res/sound/` folders into `/sdcard/QuestShock/res/`.
|
||||||
|
4. Launch it again. The left controller's menu button opens the menu; the
|
||||||
|
default controls are in "5.1.1. Menu, controls and cheats" below.
|
||||||
|
|
||||||
|
Section "5.1. Installing and playing" has the details.
|
||||||
|
|
||||||
## 1. Design principles
|
## 1. Design principles
|
||||||
|
|
||||||
- **Standalone-headset-only.** The game must run entirely on the headset
|
- **Standalone-headset-only.** The game must run entirely on the headset
|
||||||
@@ -19,7 +69,11 @@ a cross-platform port of the original game.
|
|||||||
## 2. Layout
|
## 2. Layout
|
||||||
|
|
||||||
- `engine/` - a vendored snapshot of the Shockolate engine source. Built
|
- `engine/` - a vendored snapshot of the Shockolate engine source. Built
|
||||||
via Docker; see "4. Desktop build" below.
|
via Docker; see "4. Desktop build" below. `engine-patches/`
|
||||||
|
holds general (not Android-specific) fixes applied to a scratch copy at
|
||||||
|
build time - same convention as `android/engine-patches/` below, but
|
||||||
|
for the Linux/Windows desktop builds; `engine/` itself is never
|
||||||
|
modified. See "4.4. Engine patch reference" below for what each one does.
|
||||||
- `build-image/` - the Dockerfile (and supporting scripts) for the engine
|
- `build-image/` - the Dockerfile (and supporting scripts) for the engine
|
||||||
build environment. Every third-party dependency the engine needs to
|
build environment. Every third-party dependency the engine needs to
|
||||||
compile (SDL2, SDL2_mixer, the fluidsynth-lite MIDI synth, a MIDI
|
compile (SDL2, SDL2_mixer, the fluidsynth-lite MIDI synth, a MIDI
|
||||||
@@ -30,7 +84,23 @@ a cross-platform port of the original game.
|
|||||||
- `res/assets/` - where you place your own purchased copy of the game (see
|
- `res/assets/` - where you place your own purchased copy of the game (see
|
||||||
"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` / `res/run.bat` - the launcher scripts, copied into
|
||||||
|
`dist`/`dist-win` on build. `res/run-mods.sh` / `res/run-mods.bat` are
|
||||||
|
companion launchers that additionally load every mod built under the
|
||||||
|
`modding/` subproject's own `dist/mods/` (see
|
||||||
|
[modding/README.md](modding/README.md) section 4.3) - copied into
|
||||||
|
`dist`/`dist-win` the same way, but not into the redistributable
|
||||||
|
`package`/`package-win` archives, since that build output is local and
|
||||||
|
git-ignored.
|
||||||
|
- `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 +119,16 @@ 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,
|
||||||
|
patched copies) and the Android prebuilts with host-resolvable paths,
|
||||||
|
so Android Studio can compile and deploy `android/` natively instead
|
||||||
|
of inside the build-image. See "5.2. Building natively in Android
|
||||||
|
Studio" below.
|
||||||
- `clean` - removes all build output (`dist/`, `build/`, compiled
|
- `clean` - removes all build output (`dist/`, `build/`, compiled
|
||||||
engine artifacts, and the staged Android project files).
|
engine artifacts, and the staged Android project files).
|
||||||
- `android/` - the Quest app (Java `SDLActivity` glue, Gradle project).
|
- `android/` - the Quest app (Java `SDLActivity` glue, Gradle project).
|
||||||
@@ -85,12 +162,51 @@ package`/`make apk` pick it up the same way either way. This one copy of
|
|||||||
game assets feeds both the desktop build and the Android/Quest build
|
game assets feeds both the desktop build and the Android/Quest build
|
||||||
below.
|
below.
|
||||||
|
|
||||||
|
**Note on French/German**: the GOG Enhanced Edition installer only ships
|
||||||
|
English MFD (Multi-Function Display - the small in-game screens showing
|
||||||
|
the map, inventory, etc.) art resources (`mfdart.res`) - it doesn't include
|
||||||
|
`mfdfrn.res`/`mfdger.res`, which French/German need in addition to their
|
||||||
|
own text resources (`frnstrng.res`/`gerstrng.res`, which the installer
|
||||||
|
*does* include). Without those two files, switching to French/German (via
|
||||||
|
`questshock.config.json`'s `general.language` or the in-headset menu's
|
||||||
|
Language row - see [CONFIG.md](CONFIG.md)) is refused with an in-headset
|
||||||
|
popup rather than applied, since doing so anyway used to crash the game. If
|
||||||
|
you also own **System Shock: Classic** on gog.com (which does include both
|
||||||
|
files), drop its offline installer into `res/assets/` alongside the
|
||||||
|
Enhanced Edition one and `extract_assets.sh` pulls them out of it too,
|
||||||
|
entirely optionally - it still runs fine with just the Enhanced Edition
|
||||||
|
installer.
|
||||||
|
|
||||||
|
The intro cutscene follows the selected language too, when the matching
|
||||||
|
voice-over file (`svfrintr.res`/`svgeintr.res` under `res/data/`) is present;
|
||||||
|
otherwise the English intro plays.
|
||||||
|
|
||||||
## 4. Desktop build
|
## 4. Desktop build
|
||||||
|
|
||||||
Builds and runs Questshock natively on Linux (your dev machine, or any
|
Builds and runs Questshock natively on the desktop (your dev machine, or
|
||||||
Linux box) - useful for local development and testing without a VR
|
any Linux/Windows box) - useful for local development and testing
|
||||||
headset at all. For the VR-headset build, see "5. Android / Quest build"
|
without a VR headset at all. For the VR-headset build, see "5. Android /
|
||||||
below instead.
|
Quest build" below instead.
|
||||||
|
|
||||||
|
Two platforms: Linux (native) and Windows (x86_64, cross-compiled via
|
||||||
|
MinGW - see "4.2. Windows cross-build" below for how). `make dist`/`make
|
||||||
|
package` build **both** by default; use `make dist-linux`/`make
|
||||||
|
dist-win` (or `package-linux`/`package-win`) to build just one.
|
||||||
|
|
||||||
|
**Prerequisites:**
|
||||||
|
|
||||||
|
- **Docker** - `build-image.sh`/`run-image.sh` build and run the engine
|
||||||
|
build-image; no other build tool touches the host directly. The
|
||||||
|
Windows cross-toolchain (MinGW) is baked into the same build-image, so
|
||||||
|
building `dist-win` needs nothing extra beyond Docker itself.
|
||||||
|
- **`innoextract` and `unzip`** - optional, only needed by
|
||||||
|
`res/assets/extract_assets.sh` (see "3. Game assets" above) to unpack
|
||||||
|
the GOG installer locally; if either is missing, that script falls
|
||||||
|
back to running the extraction inside a throwaway Docker container
|
||||||
|
instead.
|
||||||
|
- **`zip`** - only needed on the host for `make package`/`package-win`
|
||||||
|
(the Windows release archive); `make dist`/`dist-win` and
|
||||||
|
`package-linux` don't need it.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# 1. Build the engine build-image (once, or after build-image/ changes)
|
# 1. Build the engine build-image (once, or after build-image/ changes)
|
||||||
@@ -99,11 +215,12 @@ below instead.
|
|||||||
# 2. Get your own copy of the game data (see "3. Game assets" above), then:
|
# 2. Get your own copy of the game data (see "3. Game assets" above), then:
|
||||||
res/assets/extract_assets.sh
|
res/assets/extract_assets.sh
|
||||||
|
|
||||||
# 3. Compile the engine and assemble dist/
|
# 3. Compile the engine and assemble dist/ (Linux) and dist-win/ (Windows)
|
||||||
make dist
|
make dist
|
||||||
|
|
||||||
# 4. Play
|
# 4. Play
|
||||||
dist/run.sh
|
dist/run.sh # Linux
|
||||||
|
dist-win/run.bat # Windows (or systemshock.exe directly)
|
||||||
```
|
```
|
||||||
|
|
||||||
`make dist` always recompiles the engine from the current `engine/`
|
`make dist` always recompiles the engine from the current `engine/`
|
||||||
@@ -112,35 +229,234 @@ source (via `run-image.sh`), so a fresh build-image plus a re-run of
|
|||||||
|
|
||||||
### 4.1. Packaging a distributable build
|
### 4.1. Packaging a distributable build
|
||||||
|
|
||||||
`make package` builds `dist/shockolate-<version>-linux-<arch>.tar.gz`: the
|
`make package` builds both `dist/shockolate-<version>-linux-<arch>.tar.gz`
|
||||||
compiled binary, its runtime libraries, shaders, a default MIDI
|
and `dist/shockolate-<version>-windows-x86_64.zip`: the compiled
|
||||||
soundfont, license information, and `res/GET_ASSETS.txt` in place of the
|
binary/DLLs, shaders, a default MIDI soundfont, license information, and
|
||||||
actual game data (which the tarball never includes). Version comes from
|
`res/GET_ASSETS.txt` in place of the actual game data (which neither
|
||||||
the current git tag (push a `vX.Y.Z` tag to drive a release); without one
|
archive ever includes). Version comes from the current git tag (push a
|
||||||
it builds an untagged `0.0.0-dev+<sha>` placeholder. `make apk` (see
|
`vX.Y.Z` tag to drive a release); without one it builds an untagged
|
||||||
"5. Android / Quest build" below) is versioned identically, via the same
|
`0.0.0-dev+<sha>` placeholder. `make apk` (see "5. Android / Quest build"
|
||||||
`build-image/version.sh`.
|
below) is versioned identically, via the same `build-image/version.sh`.
|
||||||
|
|
||||||
|
### 4.2. Windows cross-build
|
||||||
|
|
||||||
|
`make dist-win`/`make package-win` cross-compile a native Windows x86_64
|
||||||
|
build via MinGW (`x86_64-w64-mingw32-gcc`/`g++`, baked into the
|
||||||
|
build-image alongside the Linux toolchain - see
|
||||||
|
`build-image/build-engine-win.sh` and the "Windows cross-compile" section
|
||||||
|
of `build-image/Dockerfile`) - no Windows machine, Wine, or VM involved
|
||||||
|
in building it. `engine/CMakeLists.txt` already has working `WIN32`/
|
||||||
|
`MINGW` branches from Shockolate's own upstream Windows build (built
|
||||||
|
natively via Git Bash/MinGW on a real Windows machine - see
|
||||||
|
`engine/build_win64.sh`/`engine/appveyor.yml`) - this cross-compiles those
|
||||||
|
same branches offline and reproducibly, from Linux, via Docker. It applies
|
||||||
|
the same `engine-patches/` series the Linux build does (section
|
||||||
|
4.4); unlike the Quest build's `android/engine-patches/`, no
|
||||||
|
Windows-specific source patching is needed on top of that.
|
||||||
|
|
||||||
|
`dist-win/`/the packaged `.zip` ship `systemshock.exe` with its DLLs
|
||||||
|
(SDL2, SDL2_mixer, GLEW, fluidsynth-lite, plus the MinGW pthread runtime)
|
||||||
|
sitting flat alongside it, rather than in a `lib/` subdirectory like
|
||||||
|
`dist/` - Windows' default DLL search order already checks the
|
||||||
|
executable's own directory first, so no `PATH`/library-path setup is
|
||||||
|
needed the way `dist/run.sh` needs `LD_LIBRARY_PATH`.
|
||||||
|
|
||||||
|
Confirmed working (and playable) on a real Windows machine.
|
||||||
|
|
||||||
A Gitea Actions workflow (`.gitea/workflows/build.yml`) builds this
|
A Gitea Actions workflow (`.gitea/workflows/build.yml`) builds this
|
||||||
package on every push, using the build-image as its container (so no
|
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.
|
||||||
|
|
||||||
|
### 4.4. Engine patch reference
|
||||||
|
|
||||||
|
`engine-patches/` (applied to a scratch copy of `engine/` by
|
||||||
|
`build-image/build-engine.sh` and `build-engine-win.sh`) is a plain,
|
||||||
|
numbered patch series, same convention as the Quest build's
|
||||||
|
`android/engine-patches/` (section 5.4) - `engine/` itself is never
|
||||||
|
modified. It only carries fixes that are general (not Android-specific);
|
||||||
|
anything Quest/OpenXR-only belongs in `android/engine-patches/` instead,
|
||||||
|
which the desktop build does not apply.
|
||||||
|
|
||||||
|
- **`01-desktop-texture-row-length.patch`** - `opengl_cache_texture()`/
|
||||||
|
`set_texture()` (`OpenGL.cc`) passed the original 8-bit bitmap's own row
|
||||||
|
stride (`bm->row`) to `GL_UNPACK_ROW_LENGTH`, describing the wrong
|
||||||
|
layout to OpenGL for the actually-uploaded buffer (a separately
|
||||||
|
allocated RGBA32 conversion, `converted->pixels`) whenever the two
|
||||||
|
strides differ - corrupting the texture upload. Uses
|
||||||
|
`converted->pitch / 4` instead.
|
||||||
|
- **`02-desktop-audio-error-detail.patch`** - logs `SDL_GetError()`
|
||||||
|
alongside "Couldn't open audio device" instead of just the bare message.
|
||||||
|
- **`03-desktop-fix-mod-resource-priority.patch`** - `LoadModFiles()`
|
||||||
|
(`Modding.c`) registers mod resource files once at startup, but two
|
||||||
|
places re-open the base game's own object/texture art files afterward -
|
||||||
|
and opening a resource file re-registers every ID it contains,
|
||||||
|
unconditionally, with no check for whether a mod already claimed it (see
|
||||||
|
`ResReadDirEntries()` in `resfile.c`). That silently reverted any mod
|
||||||
|
override for that art back to the base game's own graphics, twice over:
|
||||||
|
`obj_set_secondary_properties()` (`objsim.c`) re-opens `objart2.res`/
|
||||||
|
`objart3.res` transiently, just to read frame-count metadata; later in
|
||||||
|
the same startup sequence, `load_dynamic_memory()` (`cybmem.c`) re-opens
|
||||||
|
`texture.res`/`handart.res`/`objart2.res`/`objart3.res` again, this time
|
||||||
|
persistently - these are the handles actual gameplay renders from, so
|
||||||
|
this second point is what mattered for the mod override actually
|
||||||
|
showing up in-game. Fixed by re-running `LoadModFiles()` right after each
|
||||||
|
place's re-opens, so mods regain priority. Re-registering a mod file's
|
||||||
|
resource IDs means actually reopening it (there's no "just re-register"
|
||||||
|
primitive), so `LoadModFiles()` now closes each mod file's previous
|
||||||
|
handle first - one open handle per mod file at a time, rather than
|
||||||
|
leaking a fresh file slot on every repeat call (a new game or a loaded
|
||||||
|
save can both re-run this whole sequence).
|
||||||
|
- **`04-desktop-fix-critter-sprite-scale.patch`** - critters render as
|
||||||
|
camera-facing 2D billboards (`gameobj.c`'s `FAUBJ_CRIT` case), not
|
||||||
|
UV-mapped 3D polygons, and their on-screen size comes directly from the
|
||||||
|
bitmap's own pixel width/height (`Bitmap.c`'s `g3_bitmap_common()`, via
|
||||||
|
`_g3d_bitmap_u_anchor`/`_g3d_bitmap_v_anchor` and `bm_w`/`bm_h`) - so a
|
||||||
|
mod that replaces a critter's art with a higher-resolution bitmap for
|
||||||
|
more detail made the critter itself render bigger, proportionally to
|
||||||
|
the resolution increase. Fixed by caching every critter view's base-game
|
||||||
|
bitmap size (`objsim.c`'s `cache_critter_native_size()`/
|
||||||
|
`get_critter_native_size()`, keyed by resource ID) while
|
||||||
|
`objart2.res`/`objart3.res`'s base-game data is still the active
|
||||||
|
resolver in `obj_set_secondary_properties()` (before patch 03's
|
||||||
|
`LoadModFiles()` call hands priority to a mod), then using that cached
|
||||||
|
size instead of the bitmap's own (possibly modded) dimensions when
|
||||||
|
computing the sprite's on-screen extent - threaded through
|
||||||
|
`_fr_draw_bitmap()` and `g3_light_anchor_bitmap()`'s new `native_w`/
|
||||||
|
`native_h` parameters, consumed and reset by `g3_bitmap_common()` so an
|
||||||
|
unrelated bitmap draw never inherits a stale override. A mod's higher
|
||||||
|
resolution now only changes visible detail, not the object's size.
|
||||||
|
This depends on the mod's bitmap keeping a real anchor point in its own
|
||||||
|
`Header.Area`, in the original, un-upscaled unit space (see
|
||||||
|
`modding/src/mdlp/upscale.go`'s `imageToBitmap()`) - `_fr_draw_bitmap()`
|
||||||
|
only forwards `native_w`/`native_h` down the anchor-aware rendering path
|
||||||
|
when given a positive anchor, so a zeroed-out one silently skips this
|
||||||
|
patch's override entirely, and an anchor scaled to the mod's higher
|
||||||
|
resolution instead of left in the original unit space visibly offsets
|
||||||
|
the sprite from where it should sit, since it shares its coordinate
|
||||||
|
system with this patch's own size override rather than with the
|
||||||
|
bitmap's real pixel dimensions.
|
||||||
|
|
||||||
## 5. Android / Quest build
|
## 5. Android / Quest build
|
||||||
|
|
||||||
`make apk` builds `dist/questshock-<version>-android-arm64.apk` - an
|
**Prerequisites:**
|
||||||
|
|
||||||
|
- **Docker** - same build-image as the desktop build (see "4. Desktop
|
||||||
|
build" above), plus network access at build time for Gradle/AGP's own
|
||||||
|
dependency resolution (the one target that isn't fully offline).
|
||||||
|
- **SideQuest or `adb`** (Android Platform Tools) - to sideload the built
|
||||||
|
APK onto the headset, and to enable/verify USB debugging; see "5.1.
|
||||||
|
Installing and playing" below.
|
||||||
|
- Building/debugging natively in Android Studio instead of via `make
|
||||||
|
apk` needs its own separate toolchain - see "5.2. Building natively in
|
||||||
|
Android Studio" below.
|
||||||
|
|
||||||
|
`make apk` builds `dist/questshock-<version>-android-arm64.apk` (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
|
||||||
vendor's store. The game's own rendering is unchanged (still a flat, 2D
|
vendor's store. The game's own rendering is unchanged (still a flat, 2D
|
||||||
render, no stereo 3D scene), but instead of running as a Home-hosted 2D
|
render, no stereo 3D scene), but instead of running as a Home-hosted 2D
|
||||||
panel it's now shown as a single head-tracked quad floating in front of
|
panel it's now shown as a single head-tracked quad floating in front of
|
||||||
the viewer in an otherwise empty space. There's no controller-driven
|
the viewer, with a separate menu quad toggled by a controller button and
|
||||||
interaction yet (that's ongoing work - a laser-pointer-driven menu and
|
driven by a laser-pointer-style aim ray from each hand
|
||||||
on-screen keyboard); for now, play with a Bluetooth mouse/keyboard
|
(`android/app/src/main/cpp/xr_input.c`) - point and pull the trigger to
|
||||||
connected to the headset same as before. (Only tested on Meta Quest so
|
interact with it, same as the game's own Bluetooth mouse/keyboard input
|
||||||
far - the steps below use Quest-specific tool names where relevant, but
|
otherwise works unchanged.
|
||||||
the same `adb install` flow applies to any Android headset with USB
|
|
||||||
|
The menu (`MenuOverlay.java`, described in "5.1.1. Menu, controls and
|
||||||
|
cheats" below) and the keyboard (`KeyboardOverlay.java`) are two fully
|
||||||
|
independent quads, each its own `XrOverlay` instance
|
||||||
|
(`android/app/src/main/cpp/xr_overlay.c` - a generic "off-screen Android View
|
||||||
|
rendered into an OpenXR quad, hit-tested via a laser pointer" module shared by
|
||||||
|
both) with its own swapchain, visibility, and position, so they can be
|
||||||
|
shown/hidden/moved independently - the keyboard can stay open while also
|
||||||
|
picking something from the menu. The controller's menu button only ever
|
||||||
|
opens/closes the menu; the menu's "Keyboard" button only opens the keyboard
|
||||||
|
(never touching the menu's own visibility) - the keyboard's own title bar
|
||||||
|
holds the only way to close it again.
|
||||||
|
|
||||||
|
The keyboard is a hand-built, full US-layout key grid (there's no system
|
||||||
|
IME to borrow once immersive) that forwards each key press straight to the
|
||||||
|
game as real input - non-printable keys (Esc/Enter/Backspace/Tab/arrows/
|
||||||
|
F1-F12) as a `SDLActivity.onNativeKeyDown()`/`onNativeKeyUp()` pair, the
|
||||||
|
same one a physical Bluetooth keyboard's presses already go through, and
|
||||||
|
printable keys (letters, digits, punctuation, Space) via a synthesized
|
||||||
|
`SDL_TEXTINPUT` event pushed directly from native code
|
||||||
|
(`questshock_native.c`). A Shift key toggles the whole grid between
|
||||||
|
lowercase/numbers and uppercase/symbols (doubling as Caps Lock - it stays
|
||||||
|
toggled until pressed again), and Ctrl/Alt arm as one-shot modifiers,
|
||||||
|
consumed by whichever key is pressed next - meant as a general stand-in
|
||||||
|
for keyboard-driven functionality that isn't (yet, or ever) mapped onto
|
||||||
|
the controllers, not just a future config screen's text entry.
|
||||||
|
|
||||||
|
Once opened, the keyboard stays up as a standing input panel while
|
||||||
|
actually playing rather than a modal you open and close. Its title bar
|
||||||
|
doubles as a drag handle (grab-and-drag with the trigger, handled entirely
|
||||||
|
on the native side in `xr_input.c` before it ever reaches
|
||||||
|
`KeyboardOverlay`'s own touch dispatch) for repositioning it, and holds
|
||||||
|
the Close button that hides it. It's also rendered semi-transparent
|
||||||
|
(`XR_COMPOSITION_LAYER_BLEND_TEXTURE_SOURCE_ALPHA_BIT` in `xr_session.c`,
|
||||||
|
set only on the keyboard's layer - the menu launcher stays fully opaque)
|
||||||
|
so whatever's behind it - the game quad, mid-play - stays visible while
|
||||||
|
it's up.
|
||||||
|
|
||||||
|
While the menu launcher or keyboard is open, each hand also gets a thin,
|
||||||
|
colored laser-beam quad (cyan left, amber right) from the controller to
|
||||||
|
wherever its ray currently crosses that panel's plane - a second,
|
||||||
|
billboarded `XrCompositionLayerQuad` per hand (`xr_input_build_beam()` in
|
||||||
|
`xr_input.c`), oriented so it reads as a line from the viewer's eye
|
||||||
|
regardless of angle, since the flat cross-shaped reticle used while
|
||||||
|
aiming at the game quad is drawn directly into the game quad's own
|
||||||
|
texture and can't represent a ray traversing real 3D space. Aiming at the
|
||||||
|
game quad itself still uses that flat reticle, unchanged. (Only tested on
|
||||||
|
Meta Quest so far -
|
||||||
|
the steps below use Quest-specific tool names where relevant, but the
|
||||||
|
same `adb install` flow applies to any Android headset with USB
|
||||||
debugging enabled.)
|
debugging enabled.)
|
||||||
|
|
||||||
### 5.1. Installing and playing
|
### 5.1. Installing and playing
|
||||||
@@ -148,16 +464,67 @@ 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,
|
||||||
|
`questshock.config.json`, loaded automatically at startup and saved/loaded
|
||||||
|
from the in-headset menu - see [CONFIG.md](CONFIG.md) for the documented
|
||||||
|
options. Every launch also refreshes `questshock.config.default.json` (the
|
||||||
|
bundled defaults, used when there is no `questshock.config.json`) and
|
||||||
|
`controller_profiles/` (controller illustrations for the Controller Options
|
||||||
|
tab) there; your own saved config and any extra profiles you add are left
|
||||||
|
alone.
|
||||||
|
|
||||||
|
#### 5.1.1. Menu, controls and cheats
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
`make apk` always compiles the engine and links the APK inside the
|
`make apk` always compiles the engine and links the APK inside the
|
||||||
@@ -168,11 +535,12 @@ that happens inside a container it isn't running.
|
|||||||
To have Android Studio compile and deploy `android/` itself instead:
|
To have Android Studio compile and deploy `android/` itself instead:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
./run-image.sh bash build-image/prepare-android-project.sh --host-paths
|
make android-studio
|
||||||
```
|
```
|
||||||
|
|
||||||
This stages everything `make apk` normally stages (scratch, patched copies
|
(equivalent to `./run-image.sh bash build-image/prepare-android-project.sh
|
||||||
of `engine/` and `android/gl4es-src/`; the Android
|
--host-paths` directly.) This stages everything `make apk` normally stages
|
||||||
|
(scratch, patched copies of `engine/` and `android/gl4es-src/`; the Android
|
||||||
SDL2/SDL2_mixer/fluidsynth-lite/openxr prebuilts; bundled assets) - the
|
SDL2/SDL2_mixer/fluidsynth-lite/openxr prebuilts; bundled assets) - the
|
||||||
same as `build-apk.sh`'s own prep step - except it writes
|
same as `build-apk.sh`'s own prep step - except it writes
|
||||||
`android/engine.properties` with paths that resolve on your host
|
`android/engine.properties` with paths that resolve on your host
|
||||||
@@ -184,12 +552,13 @@ Studio's own native build (a CMake subdirectory of `engine/`'s build, not
|
|||||||
a separate prebuilt step), so `android/gl4es-src/`/`android/gl4es-patches/`
|
a separate prebuilt step), so `android/gl4es-src/`/`android/gl4es-patches/`
|
||||||
changes are picked up by a normal rebuild here - no need to re-run
|
changes are picked up by a normal rebuild here - no need to re-run
|
||||||
`./build-image.sh` first, unlike `build-image/Dockerfile` changes. Re-run
|
`./build-image.sh` first, unlike `build-image/Dockerfile` changes. Re-run
|
||||||
this script whenever `engine/`, `android/engine-patches/`,
|
`make android-studio` whenever `engine/`, `android/engine-patches/`,
|
||||||
`android/gl4es-src/`, `android/gl4es-patches/`, or the prebuilt-library
|
`android/gl4es-src/`, `android/gl4es-patches/`, or the prebuilt-library
|
||||||
versions in `build-image/Dockerfile` change (the Gradle build already
|
versions in `build-image/Dockerfile` change (the Gradle build already
|
||||||
does this automatically for you via its `stageEngine` task, so this
|
does this automatically for you via its `stageEngine` task, so this
|
||||||
manual re-run is mainly useful for confirming staging succeeded on its
|
manual re-run is mainly useful for confirming staging succeeded on its
|
||||||
own).
|
own) - and always after `make clean`, which deletes
|
||||||
|
`android/engine.properties` along with everything else.
|
||||||
|
|
||||||
Then, in Android Studio, use File > Open and select the `android/`
|
Then, in Android Studio, use File > Open and select the `android/`
|
||||||
directory itself (the one containing `settings.gradle` - not the repo
|
directory itself (the one containing `settings.gradle` - not the repo
|
||||||
@@ -216,7 +585,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.
|
||||||
@@ -226,13 +595,20 @@ the same as any other Android Studio project:
|
|||||||
`QuestShock`, so filtering Logcat by that tag shows everything
|
`QuestShock`, so filtering Logcat by that tag shows everything
|
||||||
questshock-specific without gl4es/OpenXR loader/system noise (drop
|
questshock-specific without gl4es/OpenXR loader/system noise (drop
|
||||||
the filter to see those too).
|
the filter to see those too).
|
||||||
6. Only re-run `prepare-android-project.sh` manually (see above) after
|
6. Only re-run `make android-studio` manually (see above) after
|
||||||
changing `engine/`, `android/engine-patches/`, `android/gl4es-src/`,
|
changing `engine/`, `android/engine-patches/`, `android/gl4es-src/`,
|
||||||
`android/gl4es-patches/`, or the prebuilt-library versions in
|
`android/gl4es-patches/`, or the prebuilt-library versions in
|
||||||
`build-image/Dockerfile` - the Gradle build's `stageEngine` task does
|
`build-image/Dockerfile` - the Gradle build's `stageEngine` task does
|
||||||
this automatically otherwise, so plain Java/C++ edits under
|
this automatically otherwise, so plain Java/C++ edits under
|
||||||
`android/app/src/main/` just need another Run/Debug press.
|
`android/app/src/main/` just need another Run/Debug press.
|
||||||
|
|
||||||
|
**If a native change genuinely doesn't seem to take effect** even after a
|
||||||
|
normal rebuild - `stageEngine` can wrongly report `UP-TO-DATE` for a
|
||||||
|
newly-added file under `android/engine-patches/` - run
|
||||||
|
`android/force-rebuild.sh` and rebuild. For more visibility than Android
|
||||||
|
Studio's collapsed Build panel, run `./gradlew :app:assembleDebug
|
||||||
|
--console=plain` from a terminal instead.
|
||||||
|
|
||||||
### 5.4. Engine/gl4es patch reference
|
### 5.4. Engine/gl4es patch reference
|
||||||
|
|
||||||
`android/engine-patches/` (applied to a scratch copy of `engine/`) and
|
`android/engine-patches/` (applied to a scratch copy of `engine/`) and
|
||||||
@@ -262,10 +638,14 @@ each one does:
|
|||||||
emulation) for real GLES-native equivalents: a custom `pointSize`
|
emulation) for real GLES-native equivalents: a custom `pointSize`
|
||||||
uniform, a shader-side alpha discard, and native point-sprite
|
uniform, a shader-side alpha discard, and native point-sprite
|
||||||
rasterization.
|
rasterization.
|
||||||
- **`05-android-audio-driver.patch`** - forces SDL2's older OpenSL ES
|
- **`05-android-audio-driver.patch`** - forces SDL2's OpenSL ES audio
|
||||||
audio backend instead of AAudio, because AAudio only allows one open
|
backend instead of AAudio via
|
||||||
playback device and the engine opens two (cutscene audio plus
|
`SDL_SetHintWithPriority(..., SDL_HINT_OVERRIDE)` (so nothing else can
|
||||||
`Mix_OpenAudio` for SFX/MIDI).
|
take precedence and silently leave AAudio selected), and logs the
|
||||||
|
actual `SDL_GetCurrentAudioDriver()` result right after `SDL_Init()`
|
||||||
|
as a standing on-device diagnostic. Does not by itself fix SFX/MIDI
|
||||||
|
playback - see `16-android-audio-single-device.patch` and README 5.5.3
|
||||||
|
for the actual cause and fix.
|
||||||
- **`06-android-resize-event.patch`** - makes the engine react to
|
- **`06-android-resize-event.patch`** - makes the engine react to
|
||||||
`SDL_WINDOWEVENT_RESIZED`, not just `SIZE_CHANGED`, since Android's SDL
|
`SDL_WINDOWEVENT_RESIZED`, not just `SIZE_CHANGED`, since Android's SDL
|
||||||
backend only ever sends the former for surface-driven resizes.
|
backend only ever sends the former for surface-driven resizes.
|
||||||
@@ -277,13 +657,137 @@ each one does:
|
|||||||
`SDL_SetWindowFullscreen`/`SetWindowSize`/`SetWindowPosition` calls on
|
`SDL_SetWindowFullscreen`/`SetWindowSize`/`SetWindowPosition` calls on
|
||||||
Android, since there's no real desktop-style window to resize and
|
Android, since there's no real desktop-style window to resize and
|
||||||
calling them desyncs SDL's cached window size from the real surface.
|
calling them desyncs SDL's cached window size from the real surface.
|
||||||
|
Also calls `opengl_resize()` directly in `ChangeScreenSize()`
|
||||||
|
(see README 5.5.6) - skipping `SDL_SetWindowSize()` means no native
|
||||||
|
resize event ever fires on Android to trigger it when the in-game
|
||||||
|
resolution changes, so it would otherwise never re-run there.
|
||||||
- **`10-android-openxr-cmake.patch`** - wires up the OpenXR loader plus
|
- **`10-android-openxr-cmake.patch`** - wires up the OpenXR loader plus
|
||||||
EGL as link/include dependencies for the CMake build.
|
EGL as link/include dependencies for the CMake build.
|
||||||
- **`11-android-openxr-present.patch`** - the core OpenXR present-path
|
- **`11-android-openxr-present.patch`** - the core OpenXR present-path
|
||||||
hook: redirects `SDLDraw()`/`opengl_swap_and_restore()` to submit into
|
hook: redirects `SDLDraw()`/`opengl_swap_and_restore()` to submit into
|
||||||
the XR swapchain (via `xr_frame_begin()`/`xr_frame_end()`) instead of
|
the XR swapchain (via `xr_frame_begin()`/`xr_frame_end()`) instead of
|
||||||
the normal window, for both the GL-rendered 3D path and the plain
|
the normal window, for both the GL-rendered 3D path and the plain
|
||||||
software-composited path (splash screen/cutscenes/menus).
|
software-composited path (splash screen/cutscenes/menus). Also logs
|
||||||
|
once per transition between the two (`INFO("Game rendering active:
|
||||||
|
...")`) for on-device logcat diagnostics.
|
||||||
|
- **`12-android-texture-row-length.patch`** - `OpenGL.cc`'s wall/floor
|
||||||
|
texture upload (`opengl_cache_texture()`/`set_texture()`, only reachable
|
||||||
|
when `gShockPrefs.doUseOpenGL` is true - it defaults `false` in
|
||||||
|
`Prefs.c`, so this path is dormant on Quest by default) sets
|
||||||
|
`GL_UNPACK_ROW_LENGTH` from the original 8-bit source bitmap's row
|
||||||
|
stride (`bm->row`), but the buffer actually uploaded is a separately-
|
||||||
|
allocated RGBA32 conversion of it with its own, unrelated stride. Fixed
|
||||||
|
to use the converted buffer's own pitch instead.
|
||||||
|
- **`13-android-swapchain-sync.patch`** - `android_draw_surface_as_quad()`
|
||||||
|
used `glFlush()` after drawing into the swapchain's FBO, which queues
|
||||||
|
the GPU work without waiting for it to finish before
|
||||||
|
`xr_swapchain_release()` hands the image to the OpenXR runtime for
|
||||||
|
cross-process consumption. Changed to `glFinish()`, which blocks until
|
||||||
|
the write has landed. The same gap existed in `xr_overlay.c`'s
|
||||||
|
`xr_overlay_render_and_build_layer()` (the keyboard/menu overlay blit
|
||||||
|
path) and was fixed there directly, no patch needed since that file is
|
||||||
|
first-party.
|
||||||
|
- **`14-android-quad-unpack-state.patch`** - fixes the diagonal-banded/
|
||||||
|
skewed corruption in the 3D game view during real gameplay (see README
|
||||||
|
5.5.2). `android_draw_surface_as_quad()`'s `glTexImage2D` upload of the
|
||||||
|
tightly-packed `uiRgba` buffer relied on GL's default unpack state
|
||||||
|
(`GL_UNPACK_ROW_LENGTH`/`GL_UNPACK_ALIGNMENT` at 0/4) instead of setting
|
||||||
|
it explicitly. gl4es implements both client-side and persists whatever
|
||||||
|
was last set across draw calls in the same context, so this upload
|
||||||
|
inherited non-default unpack state left behind elsewhere in the frame
|
||||||
|
and misread every row. Fixed by resetting both to the packed defaults
|
||||||
|
immediately before this upload.
|
||||||
|
- **`15-android-audio-error-detail.patch`** - `snd_start_digital()`'s
|
||||||
|
`Mix_OpenAudio()` failure only logged a fixed string, not the actual
|
||||||
|
reason. Adds `SDL_GetError()` to the log line so the next on-device
|
||||||
|
failure reports the real underlying error.
|
||||||
|
- **`16-android-audio-single-device.patch`** - fixes SFX/MIDI music not
|
||||||
|
playing (see README 5.5.3). `snd_start_digital()` opened two SDL audio
|
||||||
|
devices: a raw `SDL_OpenAudioDevice()` for cutscene/audiolog audio and
|
||||||
|
a second one via `Mix_OpenAudio()` for SFX/MIDI - SDL's Android audio
|
||||||
|
backends only ever allow one open non-capture device, so the second
|
||||||
|
open always failed. Removes the raw device entirely and feeds cutscene/
|
||||||
|
audiolog PCM into the `Mix_OpenAudio()` device's output instead, via
|
||||||
|
`Mix_SetPostMix()` (mixing in rather than overwriting, so it doesn't
|
||||||
|
silence any SFX/MIDI music playing at the same time). `cutsloop.c`'s
|
||||||
|
and `audiolog.c`'s `SDL_PauseAudioDevice()` calls (no longer meaningful
|
||||||
|
without a dedicated device) are removed; the cutscene-audio-stream
|
||||||
|
pointer swaps they guarded are now protected by `SDL_LockAudio()`/
|
||||||
|
`SDL_UnlockAudio()` instead, since SDL_mixer's audio thread now reads
|
||||||
|
that pointer continuously via the post-mix hook.
|
||||||
|
- **`17-android-native-midi-unsupported.patch`** - `NativeMidiInit()` in
|
||||||
|
`MusicDevice.c` has real implementations only under `#ifdef WIN32` and
|
||||||
|
`#elif defined(USE_ALSA)`; on every other platform (Android included)
|
||||||
|
it fell through to `return 0` (success) without ever opening a device,
|
||||||
|
so a `midi-backend` preference of "Native" (see README 5.5.4) played
|
||||||
|
silently forever instead of falling back. Adds an `#else` branch that
|
||||||
|
returns failure and logs a `WARN`, so `InitDecXMI()`'s existing
|
||||||
|
fallback-to-dummy-driver logic actually triggers.
|
||||||
|
- **`18-android-opengl-immediate-mode-attribs.patch`** - fixes a black (and,
|
||||||
|
after this patch's first revision, flat-colored/untextured) screen when
|
||||||
|
switching to the OpenGL/3D render mode in-game (see README 5.5.5). Every
|
||||||
|
`glBegin(GL_TRIANGLE_STRIP)`-based draw in `OpenGL.cc` -
|
||||||
|
`opengl_swap_and_restore()`'s screen-composite quad,
|
||||||
|
`opengl_light_tmap()` (every textured wall/floor polygon, via
|
||||||
|
`draw_vertex()`), `opengl_bitmap()` (sprites), and `opengl_draw_poly()`
|
||||||
|
(flat/gouraud polygons, also via `draw_vertex()`) - used
|
||||||
|
`glVertexAttrib2f`/`glVertexAttrib1f`/`glVertex3f` immediate mode to set
|
||||||
|
per-vertex texcoords/light, which gl4es only captures per-vertex for
|
||||||
|
attribute 0 (position); custom attributes end up constant for the whole
|
||||||
|
draw call. Adds `android_imm_begin()`/`android_imm_vertex()`/
|
||||||
|
`android_imm_end()`, a small shared immediate-mode-emulation shim that
|
||||||
|
buffers vertices and flushes them as one real `glDrawArrays()` call, and
|
||||||
|
switches every one of those call sites to it, Android-only. The
|
||||||
|
starfield (`opengl_begin_stars()`/`opengl_draw_star()`/
|
||||||
|
`opengl_end_stars()`, `GL_POINTS` rather than `GL_TRIANGLE_STRIP`, up to
|
||||||
|
`NUM_STARS`=1000 per frame) had the same bug for its per-star brightness
|
||||||
|
and gets its own similarly-shaped `android_stars_begin()`/
|
||||||
|
`android_stars_vertex()`/`android_stars_end()` buffer. Also skips the
|
||||||
|
unconditional `SDL_GL_MakeCurrent(window, context)` call every
|
||||||
|
rendering function in this file had at its top, Android-only - a
|
||||||
|
worthwhile cleanup on its own merits, but *not* the source of the
|
||||||
|
persistent per-frame `GL_INVALID_OPERATION` (1282) logcat spam during
|
||||||
|
3D gameplay that this was first suspected to be (see README 5.5.8 for
|
||||||
|
that bug's actual root cause and fix, in patch 20 below).
|
||||||
|
- **`19-android-fix-hackcam-opengl-frame.patch`** - `fr_start_view()`/
|
||||||
|
`fr_send_view()` (`frsetup.c`) called `opengl_start_frame()`/
|
||||||
|
`opengl_end_frame()` again for hack-camera security-monitor renders
|
||||||
|
(which target a private software bitmap, not the GL-backed screen),
|
||||||
|
since `should_opengl_swap()` doesn't exclude them the way
|
||||||
|
`use_opengl()` already does. Adds the same exclusion to both call
|
||||||
|
sites (see README 5.5.8).
|
||||||
|
- **`20-android-no-default-framebuffer.patch`** - the actual fix for the
|
||||||
|
persistent per-frame `GL_INVALID_OPERATION` (1282) in README 5.5.8:
|
||||||
|
`opengl_end_frame()`'s `BindFrameBuffer(nullptr)` bind to framebuffer/
|
||||||
|
renderbuffer id 0 has no valid target on Android (real presentation
|
||||||
|
goes through the OpenXR swapchain's own FBOs, set up separately in
|
||||||
|
`xr_swapchain.c`) and is also functionally dead there regardless -
|
||||||
|
skipped entirely, Android-only.
|
||||||
|
- **`21-android-config.patch`** - hooks up the persistent JSON config file
|
||||||
|
(see [CONFIG.md](CONFIG.md), `qs_config.c`): one call to
|
||||||
|
`qs_config_load_startup()` in `Shock.c`'s `main()`, right after
|
||||||
|
`LoadPrefs()`, and one check in `sdl_events.c`'s `pump_events()` to drain
|
||||||
|
the custom SDL events the menu's Save/Load Configuration buttons enqueue.
|
||||||
|
- **`22-android-screen-profile.patch`** - `mainloop.c` reports every
|
||||||
|
loop-mode change to `qs_screen_profile_notify_loop_mode()`, which picks
|
||||||
|
between the initial-screen and game-screen virtual-screen presets (see
|
||||||
|
[CONFIG.md](CONFIG.md)) and tells the menu whether a game is running.
|
||||||
|
- **`23-android-intro-language.patch`** - `cutsloop.c` opens the intro
|
||||||
|
cutscene file for the current language (`svgaintr.res`/`svfrintr.res`/
|
||||||
|
`svgeintr.res`), falling back to English when it isn't installed.
|
||||||
|
- **`24-android-cyberspace-query.patch`** - adds `qs_in_cyberspace()` to
|
||||||
|
`mouselook.c`, which `xr_input.c` uses to switch between the on-foot and
|
||||||
|
cyberspace controller actions.
|
||||||
|
- **`25-android-cyberspace-analog-scale.patch`** - `movekeys.c` scales the
|
||||||
|
cyberspace pitch and bank strength by `qs_cyber_pitch_scale`/
|
||||||
|
`qs_cyber_bank_scale`, which `xr_input.c` sets from the thumbstick
|
||||||
|
deflection each frame (1.0 for keys and buttons).
|
||||||
|
- **`26-android-cheat-refill.patch`** - adds `qs_cheat_refill()` to
|
||||||
|
`hkeyfunc.c`, which tops up health, energy, ammo and items (grenades and
|
||||||
|
drugs the player already holds) for the Cheats tab's unlimited switches.
|
||||||
|
- **`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/`:
|
||||||
|
|
||||||
@@ -299,8 +803,9 @@ each one does:
|
|||||||
|
|
||||||
#### 5.5.1. The menu quad rendering the game instead of itself
|
#### 5.5.1. The menu quad rendering the game instead of itself
|
||||||
|
|
||||||
While building the OpenXR menu (`android/app/src/main/cpp/xr_menu.c`,
|
While building the OpenXR menu (`android/app/src/main/cpp/xr_overlay.c` -
|
||||||
`MenuOverlay.java`), the menu's composition-layer quad consistently showed
|
`xr_menu.c` at the time - and `MenuOverlay.java`), the menu's
|
||||||
|
composition-layer quad consistently showed
|
||||||
the game's own live rendering instead of the menu's content, even though
|
the game's own live rendering instead of the menu's content, even though
|
||||||
every diagnostic (FBO bindings, swapchain/layer submission, texture
|
every diagnostic (FBO bindings, swapchain/layer submission, texture
|
||||||
upload, viewport/scissor state) checked out correct in isolation.
|
upload, viewport/scissor state) checked out correct in isolation.
|
||||||
@@ -344,6 +849,264 @@ trigger (a single always-nonzero struct field) sits well below the
|
|||||||
project's own layer boundary and doesn't show up in any of the local GL
|
project's own layer boundary and doesn't show up in any of the local GL
|
||||||
state that the menu's own code controls.
|
state that the menu's own code controls.
|
||||||
|
|
||||||
|
#### 5.5.2. Diagonal-banded corruption in the 3D game view
|
||||||
|
|
||||||
|
During real 3D gameplay (not menus/splash), the game quad rendered as
|
||||||
|
diagonally-banded color noise. Every OpenXR overlay quad (menu, keyboard,
|
||||||
|
laser-beam pointers) and the desktop build's software renderer were
|
||||||
|
unaffected, narrowing the bug to the one present path gameplay uses that
|
||||||
|
those don't: `android_draw_surface_as_quad()` in `engine/src/MacSrc/
|
||||||
|
OpenGL.cc`.
|
||||||
|
|
||||||
|
**Root cause:** its `glTexImage2D` upload of the tightly-packed `uiRgba`
|
||||||
|
conversion buffer relies on GL's default unpack state
|
||||||
|
(`GL_UNPACK_ROW_LENGTH`/`GL_UNPACK_ALIGNMENT` at 0/4) instead of setting
|
||||||
|
it explicitly. gl4es - the GLES/EGL translation layer this engine's
|
||||||
|
desktop-style GL calls go through on Quest - implements both unpack
|
||||||
|
parameters in software, manually repacking the source buffer row-by-row
|
||||||
|
using whatever stride was last set via `glPixelStorei`, and that state
|
||||||
|
persists across draw calls in the same GL context rather than resetting
|
||||||
|
per call. During gameplay, this upload inherits non-default unpack state
|
||||||
|
left behind elsewhere in the frame and misreads every row - a diagonal
|
||||||
|
shear/banding pattern. Menus never trigger it because nothing in the
|
||||||
|
menu-only code paths sets a custom unpack stride first.
|
||||||
|
|
||||||
|
#### 5.5.3. In-game SFX and MIDI music didn't play
|
||||||
|
|
||||||
|
Intro cutscene audio played correctly, but in-game SFX and MIDI/background
|
||||||
|
music never played, on-device.
|
||||||
|
|
||||||
|
**Root cause:** `snd_start_digital()` in `engine/src/MacSrc/SDLSound.c`
|
||||||
|
opened two separate SDL audio devices - a raw `SDL_OpenAudioDevice()`
|
||||||
|
call (used for cutscene/movie audio, always succeeded) and a second
|
||||||
|
device via `Mix_OpenAudio()` (used for SFX and, via `Mix_HookMusic`,
|
||||||
|
MIDI music - always failed, `SDL_GetError()` reporting "Audio device
|
||||||
|
already open"). SDL's Android audio backends (both AAudio and OpenSL ES)
|
||||||
|
only ever allow one open non-capture device - forcing OpenSL ES instead
|
||||||
|
of AAudio (`05-android-audio-driver.patch`) doesn't change this, since
|
||||||
|
the restriction isn't backend-specific.
|
||||||
|
|
||||||
|
**Fix (`16-android-audio-single-device.patch`):** removed the raw
|
||||||
|
`SDL_OpenAudioDevice()` call entirely. Cutscene/audiolog PCM (previously
|
||||||
|
delivered via that device's own callback) is now mixed into the single
|
||||||
|
`Mix_OpenAudio()` device's output via `Mix_SetPostMix()`, additively
|
||||||
|
(`SDL_MixAudioFormat`) rather than overwritten, so SFX/MIDI music
|
||||||
|
already mixed into the buffer isn't silenced whenever a cutscene or
|
||||||
|
audiolog plays. The stream-pointer swaps in `cutsloop.c`/`audiolog.c`
|
||||||
|
that used to be guarded by pausing the now-removed device are guarded by
|
||||||
|
`SDL_LockAudio()`/`SDL_UnlockAudio()` instead, since the post-mix hook
|
||||||
|
runs continuously on SDL_mixer's own audio thread.
|
||||||
|
|
||||||
|
#### 5.5.4. MIDI music silent even after audio otherwise worked
|
||||||
|
|
||||||
|
After fixing 5.5.3, SFX and cutscene audio played correctly, but MIDI
|
||||||
|
background music was still silent, with logcat showing `Creating native
|
||||||
|
MIDI device` instead of `Creating FluidSynth MIDI device`.
|
||||||
|
|
||||||
|
**Root cause:** the engine's `midi-backend` preference (`0` = ADLMIDI,
|
||||||
|
`1` = Native, `2` = FluidSynth) is persisted in a `prefs.txt` file in the
|
||||||
|
app's private storage, which Android retains across every APK
|
||||||
|
reinstall/upgrade (only a full uninstall clears it). A stale value of
|
||||||
|
`1` (Native) from earlier testing was carried forward through every
|
||||||
|
rebuild this whole session. `SetDefaultPrefs()` (`Prefs.c`) does default
|
||||||
|
to `2` (FluidSynth) whenever `USE_FLUIDSYNTH` is defined, which it
|
||||||
|
always has been for this build - the default was never wrong, only the
|
||||||
|
persisted override. `NativeMidiInit()` has no real implementation on
|
||||||
|
Android (see `17-android-native-midi-unsupported.patch`), so it silently
|
||||||
|
reported success without opening anything.
|
||||||
|
|
||||||
|
**Fix:** a full uninstall (not just installing a new build over the old
|
||||||
|
one) clears the stale preference, after which MIDI plays correctly.
|
||||||
|
`17-android-native-midi-unsupported.patch` additionally makes
|
||||||
|
`NativeMidiInit()` report failure on unsupported platforms, so a stale
|
||||||
|
"Native" preference falls back to `InitDecXMI()`'s existing dummy-driver
|
||||||
|
path (still silent, but with a clear `WARN` in logcat) instead of
|
||||||
|
silently reporting success while never opening anything - a much faster
|
||||||
|
diagnosis if this ever recurs.
|
||||||
|
|
||||||
|
#### 5.5.5. Black, then flat-colored/untextured screen after switching to OpenGL rendering in-game
|
||||||
|
|
||||||
|
The engine has a `Ctrl+G`-bound in-game toggle (`toggle_opengl()`,
|
||||||
|
`Prefs.c`) that cycles between the plain software renderer and OpenGL
|
||||||
|
rendering. On Quest, switching to OpenGL rendering (either mode:
|
||||||
|
unfiltered or bilinear) produced a black screen for the 3D view, with
|
||||||
|
`OpenGL error: 1282` (`GL_INVALID_OPERATION`) in logcat right at the switch.
|
||||||
|
|
||||||
|
**Root cause:** `opengl_swap_and_restore()` draws the offscreen-rendered
|
||||||
|
3D view onto a screen-filling quad using `glBegin(GL_TRIANGLE_STRIP)` /
|
||||||
|
`glVertexAttrib2f()`/`glVertexAttrib1f()` / `glVertex3f()` immediate
|
||||||
|
mode to supply per-vertex texcoords and light. This is exactly the same
|
||||||
|
bug already found and fixed for the UI-overlay quad in
|
||||||
|
`11-android-openxr-present.patch`'s `android_draw_surface_as_quad()`:
|
||||||
|
under gl4es's GLES2 immediate-mode emulation, `glVertexAttrib*()` sets a
|
||||||
|
*constant* value for the whole draw call, not a fresh value per vertex
|
||||||
|
the way desktop GL's immediate mode does - only attribute 0 (position,
|
||||||
|
driven by `glVertex3f`) gets real per-vertex capture. The 3D-view quad
|
||||||
|
draw predates that fix and was never updated to match, so it kept using
|
||||||
|
the broken pattern.
|
||||||
|
|
||||||
|
This patch's first revision only fixed that one quad, which did clear the
|
||||||
|
black screen - but the next on-device test showed a "blocky" world
|
||||||
|
instead: every wall/floor polygon rendered as a single solid color
|
||||||
|
instead of its texture. The same broken immediate-mode pattern turned out
|
||||||
|
to be used by every other textured/lit polygon draw in the file too:
|
||||||
|
`opengl_light_tmap()` (every textured wall/floor polygon in the 3D view,
|
||||||
|
via the shared `draw_vertex()` helper), `opengl_bitmap()` (sprites), and
|
||||||
|
`opengl_draw_poly()` (flat/gouraud-shaded polygons, also via
|
||||||
|
`draw_vertex()`) - each polygon's texcoord/light attribute ended up
|
||||||
|
constant across all of its vertices (whatever was last set), instead of
|
||||||
|
varying per vertex, hence the flat, solid-colored blocks.
|
||||||
|
|
||||||
|
**Fix:** `18-android-opengl-immediate-mode-attribs.patch` adds
|
||||||
|
`android_imm_begin()`/`android_imm_vertex()`/`android_imm_end()`, a small
|
||||||
|
shared immediate-mode-emulation shim (buffers vertices, then issues one
|
||||||
|
real `glDrawArrays()` call), and switches every glBegin/glVertexAttrib/
|
||||||
|
glVertex3f call site above to it, Android-only - the desktop path is
|
||||||
|
unaffected and keeps the original immediate-mode code, which works
|
||||||
|
correctly under real desktop OpenGL.
|
||||||
|
|
||||||
|
The same patch also fixes the starfield
|
||||||
|
(`opengl_begin_stars()`/`opengl_draw_star()`/`opengl_end_stars()`), found
|
||||||
|
while auditing the file for every other instance of this pattern rather
|
||||||
|
than through an on-device symptom: each star is a separate `GL_POINTS`
|
||||||
|
vertex with its own brightness set via the same
|
||||||
|
`glVertexAttrib1f`/`glVertex3f` immediate-mode pair, so every star drawn
|
||||||
|
between one begin/end call would have rendered at whatever brightness was
|
||||||
|
set for the last one. Gets its own `android_stars_begin()`/
|
||||||
|
`android_stars_vertex()`/`android_stars_end()` buffer, shaped for
|
||||||
|
`GL_POINTS` and up to `NUM_STARS` (`GameSrc/rendtool.c`) vertices rather
|
||||||
|
than `android_imm_*()`'s small triangle-strip buffer.
|
||||||
|
|
||||||
|
#### 5.5.6. Display breaks and stops responding to input after changing resolution in-game
|
||||||
|
|
||||||
|
Picking a different resolution from the in-game options menu (e.g.
|
||||||
|
`1024x768`) produced a single `OpenGL error: 1282` in logcat and left the
|
||||||
|
display frozen on the last frame - `VrApi`'s FPS counter kept running at
|
||||||
|
90fps (the OpenXR compositor was still submitting frames), but nothing
|
||||||
|
in the menu responded to further input afterward.
|
||||||
|
|
||||||
|
**Root cause:** `ChangeScreenSize()` (`ShockBitmap.c`) calls
|
||||||
|
`SDL_RenderSetLogicalSize()` to the new resolution and reallocates
|
||||||
|
`drawSurface`/`offscreenDrawSurface` accordingly, but on desktop the
|
||||||
|
actual GL-side work - `opengl_resize()` in `OpenGL.cc`, which
|
||||||
|
(re)creates `backupBuffer` (the offscreen render target the 3D view
|
||||||
|
draws into) at the new size - only runs later, from the
|
||||||
|
`SDL_WINDOWEVENT_RESIZED`/`SIZE_CHANGED` event that
|
||||||
|
`SDL_SetWindowSize()` generates. `09-android-no-window-resize.patch`
|
||||||
|
skips that `SDL_SetWindowSize()` call on Android for an unrelated
|
||||||
|
reason (see its own entry above) - which also means no such event, and
|
||||||
|
therefore no `opengl_resize()` call, ever happens on Android when the
|
||||||
|
in-game resolution changes. `backupBuffer` stayed stuck at whatever size
|
||||||
|
it was created with at launch while the rest of the game moved on to
|
||||||
|
the new resolution, and that mismatch is what broke rendering and left
|
||||||
|
the menu unable to correctly hit-test further clicks.
|
||||||
|
|
||||||
|
**Fix:** `ChangeScreenSize()` now calls `opengl_resize()` directly on
|
||||||
|
Android, right after updating `gScreenWide`/`gScreenHigh` (matching the
|
||||||
|
desktop event's actual timing, since `SDL_PollEvent()` always delivers
|
||||||
|
it after `ChangeScreenSize()` has already returned) - added to
|
||||||
|
`09-android-no-window-resize.patch` since this is a direct, foreseeable
|
||||||
|
consequence of that patch's own change, not a new mechanism.
|
||||||
|
|
||||||
|
#### 5.5.7. Laser-pointer cursor disappears after changing resolution in-game
|
||||||
|
|
||||||
|
After 5.5.6's fix, changing resolution stopped breaking the display, but
|
||||||
|
the laser-pointer-driven mouse cursor (see section 5's architecture
|
||||||
|
overview) would disappear and stop being able to click anything
|
||||||
|
afterward.
|
||||||
|
|
||||||
|
**Root cause:** `xr_get_game_resolution()` (`xr_session.c`), which
|
||||||
|
`xr_input.c` uses to convert a game-quad ray hit's normalized u,v into
|
||||||
|
the pixel coordinates it feeds the engine via `xr_mouse_move()`, returned
|
||||||
|
the game quad's OpenXR swapchain's fixed pixel size
|
||||||
|
(`g_game_swapchain.width/height`) - set once at launch and never resized.
|
||||||
|
`android_draw_surface_as_quad()` (`OpenGL.cc`,
|
||||||
|
`11-android-openxr-present.patch`) always stretch-blits `drawSurface` to
|
||||||
|
fill that swapchain image via a single full-quad draw, regardless of
|
||||||
|
`drawSurface`'s own pixel size, so the swapchain's fixed size has no
|
||||||
|
actual relationship to the coordinate space `MouseX`/`MouseY` live in -
|
||||||
|
that's `drawSurface`'s own current size (`gScreenWide`/`gScreenHigh`),
|
||||||
|
which `ChangeScreenSize()` can change at runtime independently of the
|
||||||
|
swapchain. Using the stale swapchain size there mapped every ray hit
|
||||||
|
into the wrong coordinate space, moving the mouse position out of the
|
||||||
|
engine's actual screen bounds.
|
||||||
|
|
||||||
|
**Fix:** `xr_get_game_resolution()` now returns `gScreenWide`/
|
||||||
|
`gScreenHigh` instead. `xr_get_game_quad_extent()` (the quad's shape and
|
||||||
|
distance in the VR world) is intentionally left as-is, still tied to the
|
||||||
|
swapchain's fixed size - that's a real, unchanging property of the
|
||||||
|
swapchain image itself, unrelated to this bug.
|
||||||
|
|
||||||
|
#### 5.5.8. Persistent `OpenGL error: 1282` logcat spam during 3D gameplay
|
||||||
|
|
||||||
|
Every logcat capture during actual 3D gameplay showed a continuous
|
||||||
|
stream of `OpenGL error: 1282` (roughly twice per frame) from the one
|
||||||
|
pre-existing `glGetError()` check in `opengl_swap_and_restore()`, with
|
||||||
|
no visible effect on rendering (textures, resolution changes, and the
|
||||||
|
cursor all worked correctly per sections 5.5.5-5.5.7's fixes). 1282 is
|
||||||
|
`GL_INVALID_OPERATION` (0x0502) - not `GL_INVALID_ENUM` (0x0500/1280),
|
||||||
|
which earlier debugging had mistakenly assumed given the visual
|
||||||
|
symptoms resembled the immediate-mode bug in 5.5.5.
|
||||||
|
|
||||||
|
Two real, unrelated bugs turned out to be involved, found by adding a
|
||||||
|
temporary chain of `glGetError()` checkpoints across the whole
|
||||||
|
per-frame render path (`mainloop()` → `render_run()` → `fr_rend()` →
|
||||||
|
`fr_prepare_view()`/`fr_start_view()`/`fr_send_view()`) and bisecting
|
||||||
|
which ones read clean:
|
||||||
|
|
||||||
|
**xrPollEvent() leaves a stray error.** `xr_poll_events()`
|
||||||
|
(`xr_session.c`) left gl4es reporting `GL_INVALID_OPERATION` on every
|
||||||
|
call while the session is running, regardless of what this app's own
|
||||||
|
code does. Fixed by draining it (`while (glGetError() != GL_NO_ERROR)`)
|
||||||
|
right after the polling loop.
|
||||||
|
|
||||||
|
**A hack-camera render re-entered the OpenXR frame setup.**
|
||||||
|
`render_hack_cameras()` (`render.c`) calls `fr_rend()` - and so
|
||||||
|
`fr_start_view()`/`fr_send_view()` - a second time per real frame for
|
||||||
|
each active security-camera monitor, rendering into a private software
|
||||||
|
(`FR_DOUBLEB_MASK`) bitmap rather than the actual GL-backed screen.
|
||||||
|
`use_opengl()` already excludes hack-camera/pickup-mode views from the
|
||||||
|
GL texture-mapping functions (`FR_PICKUPM_MASK|FR_HACKCAM_MASK`), but
|
||||||
|
`should_opengl_swap()` - the separate check gating
|
||||||
|
`opengl_start_frame()`/`opengl_end_frame()` in those two functions -
|
||||||
|
didn't, so those ran again mid-frame for a render that was never
|
||||||
|
GL-backed. `19-android-fix-hackcam-opengl-frame.patch` adds the same
|
||||||
|
exclusion to both call sites. This is a genuine bug fixed on its own
|
||||||
|
merits, but bisecting further showed it wasn't actually the source of
|
||||||
|
the 1282 spam - removing it just relocated the checkpoint that caught
|
||||||
|
the error, without changing whether it fired.
|
||||||
|
|
||||||
|
**Actual root cause: binding "framebuffer 0" on Android has no valid
|
||||||
|
target.** `opengl_end_frame()`'s `BindFrameBuffer(nullptr)` binds
|
||||||
|
framebuffer/renderbuffer id 0 to mean "the real window surface" - true
|
||||||
|
on desktop, where that's SDL's actual GL window. On Android, this app's
|
||||||
|
real presentation target is never that window's own EGL surface at
|
||||||
|
all - it's the OpenXR swapchain's per-image FBOs
|
||||||
|
(`xr_swapchain_create()`/`xr_swapchain_acquire()`, `xr_swapchain.c`).
|
||||||
|
Whatever framebuffer/renderbuffer 0 actually resolves to for gl4es in
|
||||||
|
this configuration, binding it reliably set a persistent
|
||||||
|
`GL_INVALID_OPERATION` - confirmed by bisecting through the *entire*
|
||||||
|
render path with per-call checkpoints, all clean right up until the one
|
||||||
|
placed immediately after this exact call. The bind is also functionally
|
||||||
|
dead regardless of the error: by the time anything next draws, Shock.c's
|
||||||
|
`SDLDraw()` has already run `xr_frame_begin()` →
|
||||||
|
`xr_swapchain_acquire()`, which rebinds to that frame's real swapchain
|
||||||
|
FBO before `opengl_swap_and_restore()` (the only thing that draws before
|
||||||
|
the next `opengl_start_frame()`'s own `BindFrameBuffer(&backupBuffer)`)
|
||||||
|
runs - so nothing ever actually renders while framebuffer 0 is bound.
|
||||||
|
`20-android-no-default-framebuffer.patch` skips the call on Android
|
||||||
|
entirely. Confirmed via logcat: this fully eliminated the error across
|
||||||
|
an extended play session with 3D rendering active throughout, with no
|
||||||
|
change in what actually renders.
|
||||||
|
|
||||||
|
An earlier theory (that the OpenXR runtime's own GL work inside
|
||||||
|
`xrAcquireSwapchainImage`/`xrWaitSwapchainImage`/`xrEndFrame` was
|
||||||
|
leaving stray driver-level errors on the shared EGL context) prompted a
|
||||||
|
dlsym'd, gl4es-bypassing `glGetError()` drain at those call sites. Once
|
||||||
|
the above was fixed, testing showed that drain was never actually
|
||||||
|
necessary, so it was removed rather than kept as unproven defensive
|
||||||
|
code.
|
||||||
|
|
||||||
## 6. License
|
## 6. License
|
||||||
|
|
||||||
The original tooling in this repository (the Docker build image, build
|
The original tooling in this repository (the Docker build image, build
|
||||||
@@ -372,6 +1135,10 @@ under the **GNU GPLv3** (see `engine/LICENSE`) - it is included unchanged
|
|||||||
and is *not* relicensed by this project's MIT license. Any build or
|
and is *not* relicensed by this project's MIT license. Any build or
|
||||||
distribution of the compiled engine must comply with the GPLv3.
|
distribution of the compiled engine must comply with the GPLv3.
|
||||||
|
|
||||||
|
System Shock is a trademark of its respective owners. Questshock is an
|
||||||
|
unofficial fan project and is not affiliated with, endorsed by or supported by
|
||||||
|
them.
|
||||||
|
|
||||||
The game assets extracted into `res/assets/ss_ee/` are proprietary,
|
The game assets extracted into `res/assets/ss_ee/` are proprietary,
|
||||||
copyrighted game data owned by their respective rightsholders - they are
|
copyrighted game data owned by their respective rightsholders - they are
|
||||||
never committed to this repository (see `.gitignore`) and must be
|
never committed to this repository (see `.gitignore`) and must be
|
||||||
|
|||||||
@@ -29,9 +29,33 @@ if (prebuiltDir == null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set by build-apk.sh (see build-image/version.sh, shared with the
|
// Set by build-apk.sh (see build-image/version.sh, shared with the
|
||||||
// desktop tarball's versioning) via -PquestshockVersionName/Code. Default
|
// desktop tarball's versioning) via -PquestshockVersionName/Code. Building
|
||||||
// here only covers a direct, unsupported `gradlew` invocation.
|
// directly from Android Studio (the normal day-to-day workflow - see
|
||||||
def questshockVersionName = project.hasProperty('questshockVersionName') ? project.property('questshockVersionName') : '0.0.0-dev'
|
// README) never passes that property, so the default below has to carry
|
||||||
|
// its own weight rather than just being a fallback for some rare, other,
|
||||||
|
// unsupported path: it runs `git describe` itself, the same way
|
||||||
|
// engine/CMakeLists.txt's own PROJECT_REVERSION_STRING does for the
|
||||||
|
// engine's *own* separate version string - "vX.Y.Z" on a clean tagged
|
||||||
|
// commit, or "vX.Y.Z-N-gHASH[-dirty]" otherwise, so a build between
|
||||||
|
// releases is still identifiable from the in-headset menu (see
|
||||||
|
// MenuOverlay.java's header, which reads BuildConfig.VERSION_NAME).
|
||||||
|
def gitDescribeVersionName = {
|
||||||
|
try {
|
||||||
|
def proc = ["git", "-C", projectDir.toString(), "describe", "--tags", "--always", "--dirty"].execute()
|
||||||
|
proc.waitFor()
|
||||||
|
if (proc.exitValue() == 0) {
|
||||||
|
def out = proc.text.trim()
|
||||||
|
if (out) {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// git not on PATH, or this isn't a git checkout at all (e.g. a
|
||||||
|
// source tarball) - fall through to the placeholder below.
|
||||||
|
}
|
||||||
|
return "0.0.0-dev"
|
||||||
|
}
|
||||||
|
def questshockVersionName = project.hasProperty('questshockVersionName') ? project.property('questshockVersionName') : gitDescribeVersionName()
|
||||||
def questshockVersionCode = project.hasProperty('questshockVersionCode') ? project.property('questshockVersionCode').toInteger() : 1
|
def questshockVersionCode = project.hasProperty('questshockVersionCode') ? project.property('questshockVersionCode').toInteger() : 1
|
||||||
|
|
||||||
// Auto-refreshes the staged, patched engine/ and gl4es-src/ copies and
|
// Auto-refreshes the staged, patched engine/ and gl4es-src/ copies and
|
||||||
@@ -80,10 +104,15 @@ def stageEngineTask = tasks.register("stageEngine", Exec) {
|
|||||||
inputs.dir("${projectDir}/../gl4es-src")
|
inputs.dir("${projectDir}/../gl4es-src")
|
||||||
inputs.dir("${projectDir}/../gl4es-patches")
|
inputs.dir("${projectDir}/../gl4es-patches")
|
||||||
inputs.file("${repoRoot}/build-image/Dockerfile")
|
inputs.file("${repoRoot}/build-image/Dockerfile")
|
||||||
|
// Staged into assets/ by prepare-android-project.sh.
|
||||||
|
inputs.file("${repoRoot}/res/questshock.config.default.json")
|
||||||
|
inputs.dir("${repoRoot}/res/controller_profiles")
|
||||||
|
inputs.file("${repoRoot}/res/splash.png")
|
||||||
outputs.dir("${repoRoot}/build/android-engine")
|
outputs.dir("${repoRoot}/build/android-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")
|
||||||
outputs.file("${projectDir}/../engine.properties")
|
outputs.file("${projectDir}/../engine.properties")
|
||||||
|
outputs.dir("${projectDir}/src/main/assets")
|
||||||
}
|
}
|
||||||
|
|
||||||
// preBuild is what every variant's compile/native-build tasks already
|
// preBuild is what every variant's compile/native-build tasks already
|
||||||
@@ -110,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
|
||||||
@@ -171,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_menu.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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,12 +212,38 @@ 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generates BuildConfig (specifically BuildConfig.VERSION_NAME, read by
|
||||||
|
// MenuOverlay.java's header) - off by default since AGP 8.0, so this
|
||||||
|
// has to be requested explicitly.
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig true
|
||||||
|
}
|
||||||
|
|
||||||
lint {
|
lint {
|
||||||
abortOnError false
|
abortOnError false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
#ifndef QUESTSHOCK_QS_CONFIG_H
|
||||||
|
#define QUESTSHOCK_QS_CONFIG_H
|
||||||
|
|
||||||
|
#include <SDL.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Persistent, human-editable JSON config file - see CONFIG.md at the repo
|
||||||
|
// root for the documented schema. Lives at a plain relative path
|
||||||
|
// ("questshock.config.json") since the whole engine already runs with its
|
||||||
|
// cwd chdir()'d to the user-facing /sdcard/QuestShock/ folder (see
|
||||||
|
// QuestShockActivity.java's nativeChdir()) before main() ever runs - the
|
||||||
|
// same way prefs.txt/keybinds.txt (Prefs.c) already do.
|
||||||
|
//
|
||||||
|
// Deliberately independent of prefs.txt: this file is never read or written
|
||||||
|
// as a side effect of prefs.txt load/save, and vice versa.
|
||||||
|
|
||||||
|
// Parses the config file if it exists (silent no-op if it doesn't - this
|
||||||
|
// never creates the file itself) and applies every recognized option
|
||||||
|
// directly into gShockPrefs/mode_id. Call once, early in main() - see
|
||||||
|
// android/engine-patches/21-android-config.patch - after LoadPrefs() (so the
|
||||||
|
// config file can override prefs.txt) and before init_all() (so there's no
|
||||||
|
// live-vs-restart distinction to worry about: nothing is initialized yet, so
|
||||||
|
// every field takes full effect as part of this same startup).
|
||||||
|
void qs_config_load_startup(void);
|
||||||
|
|
||||||
|
// Called from the "Save Configuration"/"Load Configuration" button JNI
|
||||||
|
// handlers (questshock_native.c), which run on Android's UI thread. These
|
||||||
|
// only enqueue a custom SDL event - the actual file I/O and any
|
||||||
|
// gShockPrefs/mode_id/message_info() work happens on the engine's own SDL
|
||||||
|
// main thread instead (see qs_config_handle_event() below), matching this
|
||||||
|
// codebase's existing convention for crossing this exact thread boundary
|
||||||
|
// (xr_session.c's xr_poll_events(), questshock_native.c's SDL_TEXTINPUT
|
||||||
|
// push for keyboard input).
|
||||||
|
void qs_config_request_save(void);
|
||||||
|
void qs_config_request_load(void);
|
||||||
|
|
||||||
|
// Same cross-thread-dispatch reasoning as qs_config_request_save()/_load()
|
||||||
|
// above - called from the Video Options tab's controls (MenuOverlay.java),
|
||||||
|
// which are just as much on Android's UI thread as the Save/Load buttons.
|
||||||
|
// opengl=true/false selects "opengl"/"software"; resolution_index is 0-4,
|
||||||
|
// indexing the same table qs_config_current_resolution_index() below
|
||||||
|
// reads. Applies live immediately (no restart involved - see CONFIG.md);
|
||||||
|
// unlike Load Configuration, these don't post a message_info() HUD
|
||||||
|
// notification for every single tap - the tab's own selection highlight is
|
||||||
|
// the feedback.
|
||||||
|
void qs_config_request_set_renderer(bool opengl);
|
||||||
|
void qs_config_request_set_resolution(int resolution_index);
|
||||||
|
|
||||||
|
// Same cross-thread-dispatch reasoning as the two above - called from the
|
||||||
|
// Main Menu tab's Language row (MenuOverlay.java). language_index is 0
|
||||||
|
// English/1 French/2 German (gShockPrefs.goLanguage's own numbering,
|
||||||
|
// Prefs.h). Applies live immediately, the same way the game's own options
|
||||||
|
// menu applies a language change (see qs_config.c's qs_apply_language()).
|
||||||
|
void qs_config_request_set_language(int language_index);
|
||||||
|
|
||||||
|
// Same cross-thread dispatch as the three above - called from the Main Menu
|
||||||
|
// tab's "Reset Position" button. Re-anchors the player's position in front of
|
||||||
|
// the game quad (see xr_input_reset_player_position()); never reads or writes
|
||||||
|
// the config file.
|
||||||
|
void qs_config_request_reset_position(void);
|
||||||
|
|
||||||
|
// Same cross-thread-dispatch reasoning as the four above - called from the
|
||||||
|
// Video Options tab's "Initial Screen"/"Game Screen" width/distance
|
||||||
|
// preset rows (MenuOverlay.java). preset_index indexes qs_config.c's own
|
||||||
|
// shared, offset+step-generated width/distance preset lists (both in
|
||||||
|
// meters; the offset/step themselves are config-file-only, not exposed
|
||||||
|
// as buttons), read back via the four qs_config_current_*_screen_*_index()
|
||||||
|
// getters below. Applies live immediately if the just-changed profile is
|
||||||
|
// the currently active one (see CONFIG.md) - otherwise just updates the
|
||||||
|
// stored preference, invisibly, until that profile becomes active (e.g.
|
||||||
|
// tweaking "Initial Screen" mid-game).
|
||||||
|
void qs_config_request_set_initial_screen_width(int preset_index);
|
||||||
|
void qs_config_request_set_initial_screen_distance(int preset_index);
|
||||||
|
void qs_config_request_set_game_screen_width(int preset_index);
|
||||||
|
void qs_config_request_set_game_screen_distance(int preset_index);
|
||||||
|
|
||||||
|
// Cross-thread requests from the Controller Options tab (MenuOverlay.java),
|
||||||
|
// enqueued like every request_*() above. action_index indexes
|
||||||
|
// xr_input_action_name()'s list. Begin/cancel start and stop rebind capture;
|
||||||
|
// reset restores all bindings (the built-in defaults, then
|
||||||
|
// questshock.config.default.json's player_controls group if it has one).
|
||||||
|
void qs_config_request_begin_control_capture(int action_index);
|
||||||
|
void qs_config_request_cancel_control_capture(void);
|
||||||
|
void qs_config_request_reset_player_controls(void);
|
||||||
|
|
||||||
|
// Called from xr_input.c's xr_input_process_capture() on the engine's own
|
||||||
|
// thread, so no cross-thread dispatch is needed (as with
|
||||||
|
// qs_screen_profile_notify_loop_mode() below). Resolves
|
||||||
|
// MenuOverlay.onControlCaptureResult(int, String) for action_index (raw_name
|
||||||
|
// NULL on timeout or cancel), and again for displaced_action_index if the new
|
||||||
|
// binding took the input from another action (-1/NULL when none), so both
|
||||||
|
// rows can be updated together.
|
||||||
|
void qs_config_notify_control_capture_result(int action_index, const char *raw_name,
|
||||||
|
int displaced_action_index,
|
||||||
|
const char *displaced_raw_name);
|
||||||
|
|
||||||
|
// Called from xr_input.c whenever the VR menu's visibility changes. Showing
|
||||||
|
// the menu pauses a running game (not the main menu, intro or cutscenes) that
|
||||||
|
// is not already paused; hiding it unpauses only if the menu did the pausing.
|
||||||
|
// Enqueued like the request_*() functions above; the pause itself runs on the
|
||||||
|
// engine's thread at pump_events() time.
|
||||||
|
void qs_config_notify_menu_visibility(bool visible);
|
||||||
|
|
||||||
|
// Cheats tab (MenuOverlay.java). Same enqueue-only requests as above; the work
|
||||||
|
// runs on the engine's thread. cheat_id: 0 give everything, 1 toggle physics,
|
||||||
|
// 2 next level, 3 previous level, 4-7 toggle unlimited health, energy, ammo,
|
||||||
|
// items (grenades and drugs). Cheats need a running game (the unlimited
|
||||||
|
// switches can be set anywhere). The getters are plain scalar reads, safe from
|
||||||
|
// the UI thread; the unlimited mask has bit 0 health, 1 energy, 2 ammo, 3 items.
|
||||||
|
void qs_config_request_cheat(int cheat_id);
|
||||||
|
int qs_config_cheat_unlimited_mask(void);
|
||||||
|
bool qs_config_cheat_physics_running(void);
|
||||||
|
|
||||||
|
// Called once per frame from xr_input.c on the engine's thread; applies the
|
||||||
|
// unlimited-resource cheats that are switched on.
|
||||||
|
void qs_config_frame_tick(void);
|
||||||
|
|
||||||
|
// Called from xr_input.c's xr_input_suggest_bindings() when the OpenXR runtime
|
||||||
|
// rejects a supported interaction profile's suggested bindings at startup.
|
||||||
|
// That is an app bug, not a config problem, so it shows
|
||||||
|
// MenuOverlay.showFatalControllerBindingError()'s non-cancelable "Exit" dialog;
|
||||||
|
// xr_input_init() aborts right after.
|
||||||
|
void qs_config_report_fatal_input_error(const char *profile_display_name);
|
||||||
|
|
||||||
|
// Returns true and handles the event if it is one of the custom events the
|
||||||
|
// request_*()/notify functions above enqueue. Called from pump_events()
|
||||||
|
// (sdl_events.c, see android/engine-patches/21-android-config.patch) on the
|
||||||
|
// engine's thread, once per polled event. Returns false for any other event
|
||||||
|
// type, including before the custom types are registered.
|
||||||
|
bool qs_config_handle_event(const SDL_Event *event);
|
||||||
|
|
||||||
|
// Called directly (no event-queue dispatch - see
|
||||||
|
// android/engine-patches/22-android-screen-profile.patch and this
|
||||||
|
// function's own definition in qs_config.c for why) from the engine's
|
||||||
|
// mainloop.c whenever the loop mode changes, and once from
|
||||||
|
// qs_config_load_startup() - picks between the "Initial Screen" (menu/
|
||||||
|
// intro) and "Game Screen" (actual gameplay) presets based on new_mode
|
||||||
|
// (mainloop.h's SETUP_LOOP/CUTSCENE_LOOP vs. everything else) and applies
|
||||||
|
// the newly-active one's shape to xr_session.c, but only if it's actually
|
||||||
|
// different from what was already active.
|
||||||
|
void qs_screen_profile_notify_loop_mode(short new_mode);
|
||||||
|
|
||||||
|
// Read-only queries for the Video Options tab's own selection highlight
|
||||||
|
// (MenuOverlay.java re-reads these whenever the tab is (re)shown, so it
|
||||||
|
// reflects the live value even if it was last changed some other way, e.g.
|
||||||
|
// the Ctrl+G hotkey or Load Configuration). Safe to call directly from
|
||||||
|
// Android's UI thread despite not going through the event-queue dispatch
|
||||||
|
// the mutating calls above use - same low-risk reasoning already used for
|
||||||
|
// Save Configuration's own reads of gShockPrefs/mode_id: these are simple,
|
||||||
|
// independent POD scalar reads, not a sequence that needs to be atomic
|
||||||
|
// with anything else.
|
||||||
|
bool qs_config_current_renderer_is_opengl(void);
|
||||||
|
int qs_config_current_resolution_index(void);
|
||||||
|
|
||||||
|
// -1 (not just an unlikely fallback, a routine, expected result here -
|
||||||
|
// unlike qs_config_current_resolution_index() above) if the profile's
|
||||||
|
// current value doesn't exactly match one of qs_config.c's own
|
||||||
|
// offset+step-generated width/distance presets, e.g. after a hand-edited
|
||||||
|
// config file sets an in-between value - MenuOverlay.java's own
|
||||||
|
// setSelected() naturally shows no button selected in that case, no
|
||||||
|
// special-casing needed.
|
||||||
|
int qs_config_current_initial_screen_width_index(void);
|
||||||
|
int qs_config_current_initial_screen_distance_index(void);
|
||||||
|
int qs_config_current_game_screen_width_index(void);
|
||||||
|
int qs_config_current_game_screen_distance_index(void);
|
||||||
|
|
||||||
|
// Returns the value preset button `index` (0..4) currently shows for the
|
||||||
|
// width/distance option - shared by both "Initial Screen" and "Game
|
||||||
|
// Screen" columns (their offset/step is config-file-only, not per-profile
|
||||||
|
// - see qs_config.c's own comment on the underlying statics), so
|
||||||
|
// MenuOverlay.java can label its preset buttons correctly even after a
|
||||||
|
// Load Configuration changes the offset/step. Index is clamped into
|
||||||
|
// range, not validated - every index 0..4 always has a value.
|
||||||
|
float qs_config_screen_width_preset_value(int index);
|
||||||
|
float qs_config_screen_distance_preset_value(int index);
|
||||||
|
|
||||||
|
// gShockPrefs.goLanguage (0 English/1 French/2 German, Prefs.h) - read by
|
||||||
|
// MenuOverlay.java's/KeyboardOverlay.java's periodic header-refresh timer
|
||||||
|
// to pick which language's chrome text (Strings.java) to display, so a
|
||||||
|
// language change made mid-session via the game's own options menu (or our
|
||||||
|
// own Language row, or Load Configuration) is picked up without needing to
|
||||||
|
// reopen either panel; also doubles as the Main Menu tab's Language row's
|
||||||
|
// own selection highlight, same as qs_config_current_renderer_is_opengl()/
|
||||||
|
// qs_config_current_resolution_index() above. Same off-thread-scalar-read
|
||||||
|
// reasoning as those two getters.
|
||||||
|
int qs_config_current_language(void);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif // QUESTSHOCK_QS_CONFIG_H
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
#include "qs_splash.h"
|
||||||
|
|
||||||
|
#include <SDL.h>
|
||||||
|
#include <android/log.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include "xr_session.h"
|
||||||
|
|
||||||
|
#define QS_SPLASH_HOLD_MS 2500
|
||||||
|
#define QS_SPLASH_FADE_MS 500
|
||||||
|
#define QS_SPLASH_TOTAL_MS (QS_SPLASH_HOLD_MS + 2 * QS_SPLASH_FADE_MS)
|
||||||
|
// The XR session only starts once frames are polled, so the display time is
|
||||||
|
// counted from the first submitted frame; this bounds the wait for it.
|
||||||
|
#define QS_SPLASH_SESSION_TIMEOUT_MS 5000
|
||||||
|
|
||||||
|
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, "QuestShock", __VA_ARGS__)
|
||||||
|
|
||||||
|
extern void pump_events(void);
|
||||||
|
// OpenGL.cc (engine patch 11): draws a software frame into the XR game swapchain.
|
||||||
|
extern void android_composite_software_frame(SDL_Surface *ui);
|
||||||
|
|
||||||
|
static SDL_Surface *s_image = NULL;
|
||||||
|
|
||||||
|
void qs_splash_set_image(const uint32_t *argb, int width, int height) {
|
||||||
|
if (s_image != NULL) {
|
||||||
|
SDL_FreeSurface(s_image);
|
||||||
|
s_image = NULL;
|
||||||
|
}
|
||||||
|
if (argb == NULL || width <= 0 || height <= 0)
|
||||||
|
return;
|
||||||
|
s_image = SDL_CreateRGBSurfaceWithFormat(0, width, height, 32, SDL_PIXELFORMAT_ARGB8888);
|
||||||
|
if (s_image == NULL)
|
||||||
|
return;
|
||||||
|
for (int y = 0; y < height; y++)
|
||||||
|
memcpy((uint8_t *)s_image->pixels + (size_t)y * s_image->pitch, argb + (size_t)y * width,
|
||||||
|
(size_t)width * sizeof(uint32_t));
|
||||||
|
SDL_SetSurfaceBlendMode(s_image, SDL_BLENDMODE_BLEND);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0-255 brightness at `elapsed` ms after the first frame.
|
||||||
|
static int qs_splash_level(uint32_t elapsed) {
|
||||||
|
uint32_t edge = elapsed < QS_SPLASH_TOTAL_MS - elapsed ? elapsed : QS_SPLASH_TOTAL_MS - elapsed;
|
||||||
|
if (edge >= QS_SPLASH_FADE_MS)
|
||||||
|
return 255;
|
||||||
|
return (int)(255 * edge / QS_SPLASH_FADE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
void qs_splash_show(void) {
|
||||||
|
if (s_image == NULL) {
|
||||||
|
LOGI("Startup splash: no image");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int width, height;
|
||||||
|
xr_get_game_swapchain_size(&width, &height);
|
||||||
|
if (width <= 0 || height <= 0) {
|
||||||
|
LOGI("Startup splash: no XR game swapchain");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The image is stretched over the whole swapchain image, so it is
|
||||||
|
// letterboxed into a canvas of that size to keep its aspect ratio.
|
||||||
|
SDL_Surface *canvas =
|
||||||
|
SDL_CreateRGBSurfaceWithFormat(0, width, height, 32, SDL_PIXELFORMAT_RGBA32);
|
||||||
|
if (canvas == NULL)
|
||||||
|
return;
|
||||||
|
const float scale = SDL_min((float)width / s_image->w, (float)height / s_image->h);
|
||||||
|
SDL_Rect dst;
|
||||||
|
dst.w = (int)(s_image->w * scale);
|
||||||
|
dst.h = (int)(s_image->h * scale);
|
||||||
|
dst.x = (width - dst.w) / 2;
|
||||||
|
dst.y = (height - dst.h) / 2;
|
||||||
|
|
||||||
|
const uint32_t waitStart = SDL_GetTicks();
|
||||||
|
uint32_t shownStart = 0;
|
||||||
|
uint32_t elapsed = 0;
|
||||||
|
int drawnLevel = -1;
|
||||||
|
bool done = false;
|
||||||
|
|
||||||
|
while (!done) {
|
||||||
|
pump_events();
|
||||||
|
xr_poll_events();
|
||||||
|
if (xr_frame_begin()) {
|
||||||
|
if (shownStart == 0)
|
||||||
|
shownStart = SDL_GetTicks();
|
||||||
|
elapsed = SDL_GetTicks() - shownStart;
|
||||||
|
if (elapsed >= QS_SPLASH_TOTAL_MS) {
|
||||||
|
elapsed = QS_SPLASH_TOTAL_MS;
|
||||||
|
done = true;
|
||||||
|
}
|
||||||
|
const int level = qs_splash_level(elapsed);
|
||||||
|
if (level != drawnLevel) {
|
||||||
|
drawnLevel = level;
|
||||||
|
SDL_FillRect(canvas, NULL, SDL_MapRGB(canvas->format, 0, 0, 0));
|
||||||
|
SDL_SetSurfaceColorMod(s_image, level, level, level);
|
||||||
|
SDL_BlitScaled(s_image, NULL, canvas, &dst);
|
||||||
|
}
|
||||||
|
android_composite_software_frame(canvas);
|
||||||
|
} else {
|
||||||
|
SDL_Delay(10);
|
||||||
|
if (shownStart == 0 && SDL_GetTicks() - waitStart > QS_SPLASH_SESSION_TIMEOUT_MS)
|
||||||
|
done = true;
|
||||||
|
}
|
||||||
|
xr_frame_end();
|
||||||
|
}
|
||||||
|
|
||||||
|
LOGI("Startup splash: %s after %u ms", shownStart != 0 ? "shown" : "XR session did not start",
|
||||||
|
(unsigned)(SDL_GetTicks() - waitStart));
|
||||||
|
SDL_FreeSurface(canvas);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// Startup splash: the QuestShock title, shown on the game screen before the
|
||||||
|
// engine's own splash sequence.
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
// Stores a copy of the splash image (straight-alpha ARGB, one int per pixel).
|
||||||
|
// Called from JNI before the engine starts; replaces any earlier image. Without
|
||||||
|
// an image qs_splash_show() does nothing.
|
||||||
|
void qs_splash_set_image(const uint32_t *argb, int width, int height);
|
||||||
|
|
||||||
|
// Engine thread only (called from engine patch 27). Fades the image in and out
|
||||||
|
// on the game screen and returns when done, or after a few seconds if the XR
|
||||||
|
// session never starts.
|
||||||
|
void qs_splash_show(void);
|
||||||
@@ -4,12 +4,295 @@
|
|||||||
// needs, compiled into the same "main" library as engine/ (see
|
// needs, compiled into the same "main" library as engine/ (see
|
||||||
// android/engine-patches/01-android-shared-lib.patch's ANDROID_EXTRA_SOURCES)
|
// android/engine-patches/01-android-shared-lib.patch's ANDROID_EXTRA_SOURCES)
|
||||||
// - engine/ itself is never modified.
|
// - engine/ itself is never modified.
|
||||||
|
#include <ctype.h>
|
||||||
#include <jni.h>
|
#include <jni.h>
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#include <SDL.h>
|
||||||
|
|
||||||
|
#include "qs_config.h"
|
||||||
|
#include "qs_splash.h"
|
||||||
|
#include "xr_input.h"
|
||||||
|
#include "xr_overlay.h"
|
||||||
|
#include "xr_session.h"
|
||||||
|
|
||||||
JNIEXPORT void JNICALL
|
JNIEXPORT void JNICALL
|
||||||
Java_de_ladkau_questshock_QuestShockActivity_nativeChdir(JNIEnv *env, jclass clazz, jstring path) {
|
Java_de_ladkau_questshock_QuestShockActivity_nativeChdir(JNIEnv *env, jclass clazz, jstring path) {
|
||||||
const char *cpath = (*env)->GetStringUTFChars(env, path, NULL);
|
const char *cpath = (*env)->GetStringUTFChars(env, path, NULL);
|
||||||
chdir(cpath);
|
chdir(cpath);
|
||||||
(*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.handleCharKeyDown()/handleCharKeyUp()) has no real
|
||||||
|
// Android IME session behind it, so printable characters can't reach the
|
||||||
|
// game via Android's usual IME path - instead this builds and pushes an
|
||||||
|
// SDL_TEXTINPUT event directly, the same event type/shape SDL's own
|
||||||
|
// Android backend pushes for typed text, using only the public
|
||||||
|
// SDL_Event/SDL_PushEvent API. The engine's pump_events() (sdl_events.c)
|
||||||
|
// only picks up printable characters from this event, not from
|
||||||
|
// SDL_KEYDOWN.
|
||||||
|
//
|
||||||
|
// pump_events()'s SDL_TEXTINPUT handling sets sshockKeyStates[code] for
|
||||||
|
// this character and only ever clears it again on a matching SDL_KEYUP.
|
||||||
|
// Any character bound to a continuously-polled motion key (space -> jump,
|
||||||
|
// w -> forward, etc., see MacSrc/Prefs.c's default keybinds.txt) is
|
||||||
|
// polled every physics tick for as long as that state stays set, exactly
|
||||||
|
// like a real held key - so nativeSendPrintableCharDown()/Up() are called
|
||||||
|
// from the key button's own ACTION_DOWN/ACTION_UP (see
|
||||||
|
// KeyboardOverlay.charKeyTouchListener()), not both at once: pushing the
|
||||||
|
// release in the same call as the press would set and clear
|
||||||
|
// sshockKeyStates[code] within a single pump_events() call, before the
|
||||||
|
// game loop's poll (which runs once per frame, before pump_events - see
|
||||||
|
// GameSrc/mainloop.c) ever has a chance to observe the press.
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_de_ladkau_questshock_KeyboardOverlay_nativeSendPrintableCharDown(JNIEnv *env, jclass clazz,
|
||||||
|
jchar c) {
|
||||||
|
SDL_Event event;
|
||||||
|
SDL_zero(event);
|
||||||
|
event.type = SDL_TEXTINPUT;
|
||||||
|
event.text.timestamp = SDL_GetTicks();
|
||||||
|
event.text.windowID = 0;
|
||||||
|
event.text.text[0] = (char)c;
|
||||||
|
event.text.text[1] = '\0';
|
||||||
|
SDL_PushEvent(&event);
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_de_ladkau_questshock_KeyboardOverlay_nativeSendPrintableCharUp(JNIEnv *env, jclass clazz,
|
||||||
|
jchar c) {
|
||||||
|
SDL_Event releaseEvent;
|
||||||
|
SDL_zero(releaseEvent);
|
||||||
|
releaseEvent.type = SDL_KEYUP;
|
||||||
|
releaseEvent.key.timestamp = SDL_GetTicks();
|
||||||
|
releaseEvent.key.windowID = 0;
|
||||||
|
releaseEvent.key.state = SDL_RELEASED;
|
||||||
|
releaseEvent.key.repeat = 0;
|
||||||
|
// SDL keycodes for printable characters are the character's own
|
||||||
|
// (lowercased) byte value - see sdl_events.c's own comment on this.
|
||||||
|
releaseEvent.key.keysym.sym = (SDL_Keycode)tolower((unsigned char)c);
|
||||||
|
releaseEvent.key.keysym.mod = KMOD_NONE;
|
||||||
|
SDL_PushEvent(&releaseEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyboardOverlay's title-bar Close button - the keyboard is a fully
|
||||||
|
// independent overlay quad from the menu launcher (see xr_overlay.h), so
|
||||||
|
// this is the only way to hide it once it's open; the controller's menu
|
||||||
|
// button only ever affects the menu launcher.
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_de_ladkau_questshock_KeyboardOverlay_nativeRequestClose(JNIEnv *env, jclass clazz) {
|
||||||
|
xr_overlay_set_visible(xr_session_get_keyboard_overlay(), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MenuOverlay's "Keyboard" button - only opens the keyboard overlay, never
|
||||||
|
// touches the menu launcher's own visibility, so both can be shown
|
||||||
|
// together.
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_de_ladkau_questshock_MenuOverlay_nativeShowKeyboard(JNIEnv *env, jclass clazz) {
|
||||||
|
xr_overlay_set_visible(xr_session_get_keyboard_overlay(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MenuOverlay's "Save Configuration"/"Load Configuration" buttons - see
|
||||||
|
// qs_config.h for why these only enqueue an event rather than doing the
|
||||||
|
// actual file/engine-state work here directly (this runs on Android's UI
|
||||||
|
// thread, not the engine's own).
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_de_ladkau_questshock_MenuOverlay_nativeRequestSaveConfig(JNIEnv *env, jclass clazz) {
|
||||||
|
qs_config_request_save();
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_de_ladkau_questshock_MenuOverlay_nativeRequestLoadConfig(JNIEnv *env, jclass clazz) {
|
||||||
|
qs_config_request_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
// MenuOverlay's Video Options tab - live-appliable controls, see
|
||||||
|
// qs_config.h. Same enqueue-only reasoning as the Save/Load buttons above.
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_de_ladkau_questshock_MenuOverlay_nativeRequestSetRenderer(JNIEnv *env, jclass clazz,
|
||||||
|
jboolean opengl) {
|
||||||
|
qs_config_request_set_renderer(opengl);
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_de_ladkau_questshock_MenuOverlay_nativeRequestSetResolution(JNIEnv *env, jclass clazz,
|
||||||
|
jint resolutionIndex) {
|
||||||
|
qs_config_request_set_resolution(resolutionIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MenuOverlay's Main Menu tab's Language row - same enqueue-only reasoning as above.
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_de_ladkau_questshock_MenuOverlay_nativeRequestSetLanguage(JNIEnv *env, jclass clazz,
|
||||||
|
jint languageIndex) {
|
||||||
|
qs_config_request_set_language(languageIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MenuOverlay's Main Menu tab's "Reset Position" button - same enqueue-only
|
||||||
|
// reasoning as above.
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_de_ladkau_questshock_MenuOverlay_nativeRequestResetPosition(JNIEnv *env, jclass clazz) {
|
||||||
|
qs_config_request_reset_position();
|
||||||
|
}
|
||||||
|
|
||||||
|
// MenuOverlay's Video Options tab - "Initial Screen"/"Game Screen" width/
|
||||||
|
// distance preset rows, same enqueue-only reasoning as above.
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_de_ladkau_questshock_MenuOverlay_nativeRequestSetInitialScreenWidth(JNIEnv *env, jclass clazz,
|
||||||
|
jint presetIndex) {
|
||||||
|
qs_config_request_set_initial_screen_width(presetIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_de_ladkau_questshock_MenuOverlay_nativeRequestSetGameScreenWidth(JNIEnv *env, jclass clazz,
|
||||||
|
jint presetIndex) {
|
||||||
|
qs_config_request_set_game_screen_width(presetIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_de_ladkau_questshock_MenuOverlay_nativeRequestSetInitialScreenDistance(JNIEnv *env,
|
||||||
|
jclass clazz,
|
||||||
|
jint presetIndex) {
|
||||||
|
qs_config_request_set_initial_screen_distance(presetIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_de_ladkau_questshock_MenuOverlay_nativeRequestSetGameScreenDistance(JNIEnv *env, jclass clazz,
|
||||||
|
jint presetIndex) {
|
||||||
|
qs_config_request_set_game_screen_distance(presetIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read-only queries - safe to call directly (no enqueue needed, see
|
||||||
|
// qs_config.h's own comment on each of these).
|
||||||
|
JNIEXPORT jboolean JNICALL
|
||||||
|
Java_de_ladkau_questshock_MenuOverlay_nativeGetRendererIsOpenGl(JNIEnv *env, jclass clazz) {
|
||||||
|
return qs_config_current_renderer_is_opengl();
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT jint JNICALL
|
||||||
|
Java_de_ladkau_questshock_MenuOverlay_nativeGetResolutionIndex(JNIEnv *env, jclass clazz) {
|
||||||
|
return qs_config_current_resolution_index();
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT jint JNICALL
|
||||||
|
Java_de_ladkau_questshock_MenuOverlay_nativeGetInitialScreenWidthIndex(JNIEnv *env, jclass clazz) {
|
||||||
|
return qs_config_current_initial_screen_width_index();
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT jint JNICALL
|
||||||
|
Java_de_ladkau_questshock_MenuOverlay_nativeGetGameScreenWidthIndex(JNIEnv *env, jclass clazz) {
|
||||||
|
return qs_config_current_game_screen_width_index();
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT jint JNICALL
|
||||||
|
Java_de_ladkau_questshock_MenuOverlay_nativeGetInitialScreenDistanceIndex(JNIEnv *env,
|
||||||
|
jclass clazz) {
|
||||||
|
return qs_config_current_initial_screen_distance_index();
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT jint JNICALL
|
||||||
|
Java_de_ladkau_questshock_MenuOverlay_nativeGetGameScreenDistanceIndex(JNIEnv *env, jclass clazz) {
|
||||||
|
return qs_config_current_game_screen_distance_index();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preset button *labels* (not the current value) - shared by both
|
||||||
|
// "Initial Screen" and "Game Screen" columns, since their offset/step is
|
||||||
|
// config-file-only, not per-profile (see qs_config.h's own comment).
|
||||||
|
JNIEXPORT jfloat JNICALL
|
||||||
|
Java_de_ladkau_questshock_MenuOverlay_nativeGetScreenWidthPresetValue(JNIEnv *env, jclass clazz,
|
||||||
|
jint index) {
|
||||||
|
return qs_config_screen_width_preset_value(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT jfloat JNICALL
|
||||||
|
Java_de_ladkau_questshock_MenuOverlay_nativeGetScreenDistancePresetValue(JNIEnv *env, jclass clazz,
|
||||||
|
jint index) {
|
||||||
|
return qs_config_screen_distance_preset_value(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT jint JNICALL
|
||||||
|
Java_de_ladkau_questshock_MenuOverlay_nativeGetLanguage(JNIEnv *env, jclass clazz) {
|
||||||
|
return qs_config_current_language();
|
||||||
|
}
|
||||||
|
|
||||||
|
// MenuOverlay's header - polled on a periodic timer (see its own comment),
|
||||||
|
// not event-driven, so these are plain synchronous reads too.
|
||||||
|
JNIEXPORT jfloat JNICALL
|
||||||
|
Java_de_ladkau_questshock_MenuOverlay_nativeGetFps(JNIEnv *env, jclass clazz) {
|
||||||
|
return xr_session_get_fps();
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT jfloat JNICALL
|
||||||
|
Java_de_ladkau_questshock_MenuOverlay_nativeGetFrameTimeMs(JNIEnv *env, jclass clazz) {
|
||||||
|
return xr_session_get_frame_ms();
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT jfloat JNICALL
|
||||||
|
Java_de_ladkau_questshock_MenuOverlay_nativeGetCpuUtilization(JNIEnv *env, jclass clazz) {
|
||||||
|
return xr_session_get_cpu_utilization();
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT jfloat JNICALL
|
||||||
|
Java_de_ladkau_questshock_MenuOverlay_nativeGetGpuUtilization(JNIEnv *env, jclass clazz) {
|
||||||
|
return xr_session_get_gpu_utilization();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Controller Options tab: actionIndex follows xr_input.c's action order
|
||||||
|
// (MenuOverlay.java and Strings.java hold the matching labels).
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_de_ladkau_questshock_MenuOverlay_nativeRequestBeginControlCapture(JNIEnv *env, jclass clazz,
|
||||||
|
jint actionIndex) {
|
||||||
|
qs_config_request_begin_control_capture(actionIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_de_ladkau_questshock_MenuOverlay_nativeRequestCancelControlCapture(JNIEnv *env, jclass clazz) {
|
||||||
|
qs_config_request_cancel_control_capture();
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_de_ladkau_questshock_MenuOverlay_nativeRequestResetPlayerControls(JNIEnv *env, jclass clazz) {
|
||||||
|
qs_config_request_reset_player_controls();
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT jstring JNICALL
|
||||||
|
Java_de_ladkau_questshock_MenuOverlay_nativeGetControlBindingKey(JNIEnv *env, jclass clazz,
|
||||||
|
jint actionIndex) {
|
||||||
|
return (*env)->NewStringUTF(env, xr_input_get_action_binding_name(actionIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT jstring JNICALL
|
||||||
|
Java_de_ladkau_questshock_MenuOverlay_nativeGetActiveInteractionProfilePath(JNIEnv *env,
|
||||||
|
jclass clazz) {
|
||||||
|
return (*env)->NewStringUTF(env, xr_input_get_active_interaction_profile_path());
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_de_ladkau_questshock_MenuOverlay_nativeSetControllerOptionsTabOpen(JNIEnv *env, jclass clazz,
|
||||||
|
jboolean open) {
|
||||||
|
xr_input_set_controller_options_tab_open(open == JNI_TRUE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cheats tab: same enqueue-only pattern as the other requests.
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_de_ladkau_questshock_MenuOverlay_nativeRequestCheat(JNIEnv *env, jclass clazz, jint cheatId) {
|
||||||
|
qs_config_request_cheat(cheatId);
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT jint JNICALL
|
||||||
|
Java_de_ladkau_questshock_MenuOverlay_nativeGetCheatUnlimitedMask(JNIEnv *env, jclass clazz) {
|
||||||
|
return qs_config_cheat_unlimited_mask();
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT jboolean JNICALL
|
||||||
|
Java_de_ladkau_questshock_MenuOverlay_nativeGetCheatPhysicsRunning(JNIEnv *env, jclass clazz) {
|
||||||
|
return qs_config_cheat_physics_running();
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,30 @@
|
|||||||
// Controller input for questshock's immersive Quest build: one OpenXR
|
// Controller input for questshock's immersive Quest build: one OpenXR
|
||||||
// action set (aim pose + trigger click per hand, a menu-toggle button on
|
// action set (aim pose + trigger click per hand, a menu-toggle button on
|
||||||
// the left controller) plus ray/quad hit-testing. While the menu quad
|
// the left controller) plus ray/quad hit-testing. Each hand's ray is
|
||||||
// (xr_menu.c) is hidden, this tests against the game quad xr_session.c
|
// tried against the keyboard overlay, then the menu overlay (see
|
||||||
// submits and draws a small reticle where each hand's aim ray crosses it;
|
// xr_overlay.h, xr_session.c), then - only if neither actually claims it
|
||||||
// while the menu is visible, it tests against the menu quad instead and
|
// this frame (landing on it now, or continuing a touch/drag begun on a
|
||||||
// forwards trigger edges as synthetic touches (no reticle - the menu's
|
// previous frame - not merely because that overlay happens to be visible)
|
||||||
// own content comes from MenuOverlay's rendered Bitmap).
|
// - the game quad xr_session.c submits, forwarding trigger edges to
|
||||||
|
// whichever overlay claims the ray as synthetic touches. The game quad
|
||||||
|
// itself acts as a plain desktop-style mouse pointer into the engine's
|
||||||
|
// own UI (see xr_mouse.h) - absolute cursor position while the ray hits
|
||||||
|
// it, plus a left click on the trigger edge; a pending click there
|
||||||
|
// likewise keeps claiming the ray ahead of the keyboard/menu until
|
||||||
|
// released, so the eventual mouse-up isn't lost if the ray strays. Since
|
||||||
|
// none of the three targets' own feedback (the overlays' on-quad cursor,
|
||||||
|
// drawn by their Java views; the engine's own mouse cursor sprite for the
|
||||||
|
// game quad) gives any indication while the ray is short of actually
|
||||||
|
// landing on something, every claimed target also builds a thin,
|
||||||
|
// billboarded laser-beam quad per hand from the controller to wherever
|
||||||
|
// the ray currently crosses that target's plane (see
|
||||||
|
// xr_input_get_beam_layer()) - so aiming looks and behaves the same
|
||||||
|
// whether the target is an overlay or the game quad itself.
|
||||||
#ifndef QUESTSHOCK_XR_INPUT_H
|
#ifndef QUESTSHOCK_XR_INPUT_H
|
||||||
#define QUESTSHOCK_XR_INPUT_H
|
#define QUESTSHOCK_XR_INPUT_H
|
||||||
|
|
||||||
#include <stdbool.h>
|
#include <stdbool.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
#define XR_USE_PLATFORM_ANDROID 1
|
#define XR_USE_PLATFORM_ANDROID 1
|
||||||
#define XR_USE_GRAPHICS_API_OPENGL_ES 1
|
#define XR_USE_GRAPHICS_API_OPENGL_ES 1
|
||||||
@@ -22,22 +37,114 @@ extern "C" {
|
|||||||
// Call once, right after the session is created (see xr_session.c's
|
// Call once, right after the session is created (see xr_session.c's
|
||||||
// xr_create_instance_and_session()) - creates the action set/actions,
|
// xr_create_instance_and_session()) - creates the action set/actions,
|
||||||
// suggests Touch controller bindings, creates the per-hand aim action
|
// suggests Touch controller bindings, creates the per-hand aim action
|
||||||
// spaces, and attaches the set to the session. Returns false (logged,
|
// spaces and a view-space reference (for the laser-beam billboard math),
|
||||||
// non-fatal - the caller keeps rendering without input) if any of that
|
// creates the two per-hand beam swapchains (swapchain_format - the same
|
||||||
// fails.
|
// format shared by the game swapchain and both overlays), and attaches
|
||||||
bool xr_input_init(XrInstance instance, XrSession session);
|
// the action set to the session. Returns false (logged, non-fatal - the
|
||||||
|
// caller keeps rendering without input) if any of that fails.
|
||||||
|
bool xr_input_init(XrInstance instance, XrSession session, int64_t swapchain_format);
|
||||||
|
|
||||||
// Call once per frame from xr_frame_end(), before releasing the acquired
|
// Call once per frame from xr_frame_end(), before releasing the acquired
|
||||||
// swapchain image - syncs this frame's action states (always, so edge
|
// swapchain image - syncs this frame's action states (always, so edge
|
||||||
// detection stays correct even on frames with nothing to draw), handles
|
// detection stays correct even on frames with nothing to draw), handles
|
||||||
// the menu_toggle button's edge, and either draws a game-quad reticle or
|
// the menu_toggle button's edge, and hit-tests/dispatches each hand's ray
|
||||||
// forwards menu-quad touches, per the menu's current visibility (see the
|
// against keyboard/menu/game quad in that priority order (see the file
|
||||||
// file comment above). draw gates only the reticle - if false (nothing to
|
// comment above). draw gates only the laser-beam visuals (and the
|
||||||
// draw into this frame, e.g. no swapchain image was acquired), hit-testing
|
// head-pose locate that feeds them) - if false (nothing to draw into this
|
||||||
// and touch-forwarding still run. baseSpace/time must match whatever
|
// frame, e.g. no swapchain image was acquired), hit-testing and
|
||||||
|
// touch-forwarding still run. baseSpace/time must match whatever
|
||||||
// xr_frame_begin() used to predict this frame.
|
// xr_frame_begin() used to predict this frame.
|
||||||
void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw);
|
void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw);
|
||||||
|
|
||||||
|
// Call once per hand (hand: 0=left, 1=right) after xr_input_sync_and_draw()
|
||||||
|
// in the same frame - if that hand's ray claimed the keyboard, menu, or
|
||||||
|
// game quad this frame (see the file comment above), fills *out_quad's
|
||||||
|
// subImage/pose/size for its laser-beam ribbon and returns true.
|
||||||
|
// space/eyeVisibility/layerFlags are left for the caller to set, same
|
||||||
|
// convention as xr_overlay_render_and_build_layer(). Returns false
|
||||||
|
// (out_quad untouched) if no beam should be shown for that hand this
|
||||||
|
// frame.
|
||||||
|
bool xr_input_get_beam_layer(int hand, XrCompositionLayerQuad *out_quad);
|
||||||
|
|
||||||
|
// Recenters the player so the game quad ("virtual screen") appears directly
|
||||||
|
// ahead of wherever they are standing and facing, from their current real head
|
||||||
|
// pose. Never reads or writes questshock.config.json. Called automatically
|
||||||
|
// once, from xr_input_sync_and_draw(), on the first frame with a valid head
|
||||||
|
// pose after startup, unless a complete saved player_position was loaded (see
|
||||||
|
// xr_input_skip_startup_recenter()): a saved position is relative to a
|
||||||
|
// previous session's real-space origin, which is not stable across launches.
|
||||||
|
// Returns false (no-op) if no valid head pose is available yet.
|
||||||
|
bool xr_input_recenter_player_to_screen(void);
|
||||||
|
|
||||||
|
// The Main Menu tab's "Reset Position" button (via qs_config.c). Like
|
||||||
|
// xr_input_recenter_player_to_screen(), but re-anchors only the player's
|
||||||
|
// position to the centre in front of the game quad; the current facing (yaw)
|
||||||
|
// is kept, so the screen does not rotate to follow the gaze. Returns false
|
||||||
|
// (no-op) if no valid head pose is available yet.
|
||||||
|
bool xr_input_reset_player_position(void);
|
||||||
|
|
||||||
|
// Called once, from qs_config_load_startup() (qs_config.c), when a
|
||||||
|
// complete saved player_position was actually loaded from
|
||||||
|
// questshock.config.json - cancels the pending automatic startup recenter
|
||||||
|
// above so it doesn't immediately override the value that was just
|
||||||
|
// applied. Safe to call regardless of whether xr_input_init() has even run
|
||||||
|
// yet (this only touches the plain file-local flag, no XR calls).
|
||||||
|
void xr_input_skip_startup_recenter(void);
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Remappable controls ("player_controls" config group; see qs_config.c and
|
||||||
|
// CONFIG.md). menu_toggle is not among them and has no getter/setter: it is
|
||||||
|
// fixed (see xr_input_sync_and_draw()).
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
// Number of remappable actions and each one's index<->name mapping, which
|
||||||
|
// qs_config.c uses to parse/write the player_controls group without its own
|
||||||
|
// copy of the list. The names are the keys used in questshock.config.json;
|
||||||
|
// index_from_name() returns false for an unrecognized name.
|
||||||
|
int xr_input_action_count(void);
|
||||||
|
const char *xr_input_action_name(int action_index);
|
||||||
|
bool xr_input_action_index_from_name(const char *name, int *out_index);
|
||||||
|
|
||||||
|
// Current binding for one action, as an opaque int and as its string form
|
||||||
|
// (e.g. "right_b", "trigger", "none" for unbound), the same strings used in
|
||||||
|
// questshock.config.json and reported by capture.
|
||||||
|
int xr_input_get_action_binding(int action_index);
|
||||||
|
const char *xr_input_get_action_binding_name(int action_index);
|
||||||
|
|
||||||
|
// Sets one action's binding from a config-file string. Returns false (binding
|
||||||
|
// unchanged) for an unrecognized name, or for the symmetric "trigger"/
|
||||||
|
// "squeeze" on an action other than mouse_click/mouse_double_click. Does not
|
||||||
|
// enforce one-action-per-input the way capture does: a hand-edited file that
|
||||||
|
// binds two actions to one input is accepted as-is.
|
||||||
|
bool xr_input_set_action_binding(int action_index, const char *raw_name);
|
||||||
|
|
||||||
|
// Restores every action to its built-in default (the table xr_input_init()
|
||||||
|
// starts from). qs_config.c calls this first for "Reset to Defaults", then
|
||||||
|
// applies questshock.config.default.json's player_controls group on top if it
|
||||||
|
// has one.
|
||||||
|
void xr_input_reset_player_controls(void);
|
||||||
|
|
||||||
|
// Begins/cancels rebind capture for one action (an index into
|
||||||
|
// xr_input_action_name()'s list): the next fresh button or thumbstick-direction
|
||||||
|
// edge on any hand becomes that action's binding and is reported via
|
||||||
|
// qs_config_notify_control_capture_result(); with no press, capture cancels
|
||||||
|
// after 8 seconds. Called via qs_config.c's cross-thread request functions.
|
||||||
|
// The menu button is never captured.
|
||||||
|
void xr_input_capture_begin(int action_index);
|
||||||
|
void xr_input_capture_cancel(void);
|
||||||
|
|
||||||
|
// The OpenXR interaction profile currently bound (e.g.
|
||||||
|
// "/interaction_profiles/oculus/touch_controller"), refreshed once per frame.
|
||||||
|
// MenuOverlay.java's Controller Options tab uses it to pick a file from
|
||||||
|
// controller_profiles/ (see CONFIG.md). Empty until the first successful
|
||||||
|
// query.
|
||||||
|
const char *xr_input_get_active_interaction_profile_path(void);
|
||||||
|
|
||||||
|
// Set by MenuOverlay.java when the Controller Options tab becomes or stops
|
||||||
|
// being the menu's current tab. While it is open, the menu's thumbstick
|
||||||
|
// locomotion (snap turn/smooth move) is disabled.
|
||||||
|
void xr_input_set_controller_options_tab_open(bool open);
|
||||||
|
|
||||||
void xr_input_shutdown(void);
|
void xr_input_shutdown(void);
|
||||||
|
|
||||||
#ifdef __cplusplus
|
#ifdef __cplusplus
|
||||||
|
|||||||
@@ -1,356 +0,0 @@
|
|||||||
#include "xr_menu.h"
|
|
||||||
|
|
||||||
#include <dlfcn.h>
|
|
||||||
#include <stdlib.h>
|
|
||||||
|
|
||||||
#include <android/log.h>
|
|
||||||
#include <jni.h>
|
|
||||||
|
|
||||||
#include <EGL/egl.h>
|
|
||||||
#include <GLES3/gl3.h>
|
|
||||||
|
|
||||||
#include <SDL.h>
|
|
||||||
|
|
||||||
#define TAG "QuestShock"
|
|
||||||
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__)
|
|
||||||
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
|
|
||||||
|
|
||||||
#define MENU_WIDTH 1024
|
|
||||||
#define MENU_HEIGHT 768
|
|
||||||
|
|
||||||
// Below the game quad (QUAD_DISTANCE_METERS/QUAD_WIDTH_METERS in
|
|
||||||
// xr_session.c, 2m out) so both are visible together without overlapping.
|
|
||||||
#define MENU_DISTANCE_METERS 1.5f
|
|
||||||
#define MENU_WIDTH_METERS 0.8f
|
|
||||||
#define MENU_CENTER_X_METERS 0.0f
|
|
||||||
#define MENU_CENTER_Y_METERS -0.45f
|
|
||||||
|
|
||||||
static bool g_visible = false;
|
|
||||||
|
|
||||||
static jclass g_menu_overlay_class = NULL;
|
|
||||||
static jmethodID g_native_init_method = NULL;
|
|
||||||
static jmethodID g_take_pixels_method = NULL;
|
|
||||||
static jmethodID g_dispatch_touch_method = NULL;
|
|
||||||
|
|
||||||
// The menu overlay's content lives in this texture, uploaded by
|
|
||||||
// xr_menu_upload_source_texture_if_dirty() below. It's created through
|
|
||||||
// gl4es (glGenTextures/glBindTexture/glTexImage2D), like any other texture
|
|
||||||
// the engine itself creates, so ordinary glTexSubImage2D uploads against it
|
|
||||||
// work the normal way.
|
|
||||||
static GLuint g_source_texture = 0;
|
|
||||||
// A framebuffer with g_source_texture as its only color attachment, used as
|
|
||||||
// the read source for xr_menu_render_if_visible()'s glBlitFramebuffer()
|
|
||||||
// call. Created via the real (non-gl4es) GLES entry points below: that blit
|
|
||||||
// bypasses gl4es entirely (see xr_menu_render_if_visible() for why), so its
|
|
||||||
// source framebuffer has to be a real GL object rather than one gl4es
|
|
||||||
// tracks.
|
|
||||||
static GLuint g_source_fbo = 0;
|
|
||||||
|
|
||||||
typedef void (*PFNQSGETINTEGERV)(GLenum, GLint *);
|
|
||||||
typedef void (*PFNQSGENFRAMEBUFFERS)(GLsizei, GLuint *);
|
|
||||||
typedef void (*PFNQSDELETEFRAMEBUFFERS)(GLsizei, const GLuint *);
|
|
||||||
typedef void (*PFNQSBINDFRAMEBUFFER)(GLenum, GLuint);
|
|
||||||
typedef void (*PFNQSFRAMEBUFFERTEXTURE2D)(GLenum, GLenum, GLenum, GLuint, GLint);
|
|
||||||
typedef void (*PFNQSBLITFRAMEBUFFER)(GLint, GLint, GLint, GLint, GLint, GLint, GLint, GLint,
|
|
||||||
GLbitfield, GLenum);
|
|
||||||
|
|
||||||
// The menu's source framebuffer and its glBlitFramebuffer() copy into the
|
|
||||||
// swapchain image go through the real GLES driver rather than gl4es: gl4es's
|
|
||||||
// fixed-pipeline emulation unconditionally substitutes its own shader onto
|
|
||||||
// any gl4es-routed draw call, which would silently replace the menu's
|
|
||||||
// content with whatever the game itself last rendered (see
|
|
||||||
// xr_menu_render_if_visible()). A framebuffer blit has no shader stage at
|
|
||||||
// all, so going through the real driver for it sidesteps the problem
|
|
||||||
// entirely.
|
|
||||||
static PFNQSGETINTEGERV real_glGetIntegerv;
|
|
||||||
static PFNQSGENFRAMEBUFFERS real_glGenFramebuffers;
|
|
||||||
static PFNQSDELETEFRAMEBUFFERS real_glDeleteFramebuffers;
|
|
||||||
static PFNQSBINDFRAMEBUFFER real_glBindFramebuffer;
|
|
||||||
static PFNQSFRAMEBUFFERTEXTURE2D real_glFramebufferTexture2D;
|
|
||||||
// glBlitFramebuffer is a GLES 3.0 addition; resolved via eglGetProcAddress
|
|
||||||
// rather than dlsym, since Android's driver dispatch doesn't guarantee ES3+
|
|
||||||
// symbols are dlsym-able by name from libGLESv2.so, unlike the GLES2-core
|
|
||||||
// functions above.
|
|
||||||
static PFNQSBLITFRAMEBUFFER real_glBlitFramebuffer;
|
|
||||||
|
|
||||||
static bool xr_menu_load_real_gles(void) {
|
|
||||||
void *lib = dlopen("libGLESv2.so", RTLD_NOW | RTLD_LOCAL);
|
|
||||||
if (lib == NULL) {
|
|
||||||
LOGE("XR: menu dlopen(libGLESv2.so) failed: %s", dlerror());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
real_glGetIntegerv = (PFNQSGETINTEGERV)dlsym(lib, "glGetIntegerv");
|
|
||||||
real_glGenFramebuffers = (PFNQSGENFRAMEBUFFERS)dlsym(lib, "glGenFramebuffers");
|
|
||||||
real_glDeleteFramebuffers = (PFNQSDELETEFRAMEBUFFERS)dlsym(lib, "glDeleteFramebuffers");
|
|
||||||
real_glBindFramebuffer = (PFNQSBINDFRAMEBUFFER)dlsym(lib, "glBindFramebuffer");
|
|
||||||
real_glFramebufferTexture2D = (PFNQSFRAMEBUFFERTEXTURE2D)dlsym(lib, "glFramebufferTexture2D");
|
|
||||||
real_glBlitFramebuffer = (PFNQSBLITFRAMEBUFFER)eglGetProcAddress("glBlitFramebuffer");
|
|
||||||
return real_glGetIntegerv && real_glGenFramebuffers && real_glDeleteFramebuffers &&
|
|
||||||
real_glBindFramebuffer && real_glFramebufferTexture2D && real_glBlitFramebuffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
static uint32_t g_pixel_generation = 0;
|
|
||||||
static uint32_t g_source_uploaded_generation = (uint32_t)-1;
|
|
||||||
static uint8_t *g_pixel_cache = NULL; // MENU_WIDTH*MENU_HEIGHT*4 bytes
|
|
||||||
|
|
||||||
void xr_menu_get_content_size(int *width, int *height) {
|
|
||||||
*width = MENU_WIDTH;
|
|
||||||
*height = MENU_HEIGHT;
|
|
||||||
}
|
|
||||||
|
|
||||||
// FindClass() from this thread (SDL's native thread, attached to the JVM
|
|
||||||
// via AttachCurrentThread rather than spawned from Java) resolves against
|
|
||||||
// the bootstrap classloader, which only knows framework classes - it can't
|
|
||||||
// see de.ladkau.questshock.MenuOverlay at all. Routing through the
|
|
||||||
// activity's own classloader is the standard, documented workaround.
|
|
||||||
static jclass xr_menu_find_class(JNIEnv *env, jobject activity, const char *name) {
|
|
||||||
jclass activityClass = (*env)->GetObjectClass(env, activity);
|
|
||||||
jmethodID getClassLoader =
|
|
||||||
(*env)->GetMethodID(env, activityClass, "getClassLoader", "()Ljava/lang/ClassLoader;");
|
|
||||||
jobject classLoader = (*env)->CallObjectMethod(env, activity, getClassLoader);
|
|
||||||
jclass classLoaderClass = (*env)->FindClass(env, "java/lang/ClassLoader");
|
|
||||||
jmethodID loadClass = (*env)->GetMethodID(env, classLoaderClass, "loadClass",
|
|
||||||
"(Ljava/lang/String;)Ljava/lang/Class;");
|
|
||||||
jstring className = (*env)->NewStringUTF(env, name);
|
|
||||||
jclass result = (jclass)(*env)->CallObjectMethod(env, classLoader, loadClass, className);
|
|
||||||
(*env)->DeleteLocalRef(env, className);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
static bool xr_menu_init_jni(void) {
|
|
||||||
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
|
|
||||||
jobject activity = (jobject)SDL_AndroidGetActivity();
|
|
||||||
if (env == NULL || activity == NULL) {
|
|
||||||
LOGE("XR: menu - no JNIEnv/Activity from SDL");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
jclass localClass = xr_menu_find_class(env, activity, "de.ladkau.questshock.MenuOverlay");
|
|
||||||
if (localClass == NULL) {
|
|
||||||
LOGE("XR: menu - could not find MenuOverlay class");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
g_menu_overlay_class = (jclass)(*env)->NewGlobalRef(env, localClass);
|
|
||||||
|
|
||||||
g_native_init_method =
|
|
||||||
(*env)->GetStaticMethodID(env, g_menu_overlay_class, "nativeInit", "(Landroid/app/Activity;)V");
|
|
||||||
g_take_pixels_method =
|
|
||||||
(*env)->GetStaticMethodID(env, g_menu_overlay_class, "nativeTakePixelsIfDirty", "()[B");
|
|
||||||
g_dispatch_touch_method =
|
|
||||||
(*env)->GetStaticMethodID(env, g_menu_overlay_class, "nativeDispatchTouch", "(FFZ)V");
|
|
||||||
if (!g_native_init_method || !g_take_pixels_method || !g_dispatch_touch_method) {
|
|
||||||
LOGE("XR: menu - could not resolve MenuOverlay JNI methods");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
(*env)->CallStaticVoidMethod(env, g_menu_overlay_class, g_native_init_method, activity);
|
|
||||||
// CallStaticVoidMethod doesn't surface Java exceptions on its own - if
|
|
||||||
// MenuOverlay's constructor throws (it runs its View measure/layout/
|
|
||||||
// draw calls on this native render thread rather than the Android UI
|
|
||||||
// thread, a plausible crash vector), the exception would otherwise be
|
|
||||||
// left silently pending and corrupt whatever JNI call runs next.
|
|
||||||
if ((*env)->ExceptionCheck(env)) {
|
|
||||||
LOGE("XR: menu MenuOverlay.nativeInit() threw a pending Java exception:");
|
|
||||||
(*env)->ExceptionDescribe(env);
|
|
||||||
(*env)->ExceptionClear(env);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
static bool xr_menu_create_source_texture(void) {
|
|
||||||
glGenTextures(1, &g_source_texture);
|
|
||||||
glBindTexture(GL_TEXTURE_2D, g_source_texture);
|
|
||||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
|
|
||||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
|
||||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
|
|
||||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
|
|
||||||
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, MENU_WIDTH, MENU_HEIGHT, 0, GL_RGBA, GL_UNSIGNED_BYTE,
|
|
||||||
NULL);
|
|
||||||
|
|
||||||
GLint prevFbo = 0;
|
|
||||||
real_glGetIntegerv(GL_FRAMEBUFFER_BINDING, &prevFbo);
|
|
||||||
real_glGenFramebuffers(1, &g_source_fbo);
|
|
||||||
real_glBindFramebuffer(GL_FRAMEBUFFER, g_source_fbo);
|
|
||||||
real_glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D,
|
|
||||||
g_source_texture, 0);
|
|
||||||
real_glBindFramebuffer(GL_FRAMEBUFFER, (GLuint)prevFbo);
|
|
||||||
|
|
||||||
return g_source_texture != 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool xr_menu_init(XrInstance instance, XrSession session) {
|
|
||||||
(void)instance;
|
|
||||||
(void)session;
|
|
||||||
|
|
||||||
if (!xr_menu_load_real_gles()) {
|
|
||||||
LOGE("XR: menu couldn't resolve real GLES functions via dlsym/eglGetProcAddress");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!xr_menu_create_source_texture())
|
|
||||||
return false;
|
|
||||||
if (!xr_menu_init_jni())
|
|
||||||
return false;
|
|
||||||
|
|
||||||
g_pixel_cache = (uint8_t *)malloc((size_t)MENU_WIDTH * MENU_HEIGHT * 4);
|
|
||||||
if (g_pixel_cache == NULL)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
LOGI("XR: menu overlay ready (%dx%d)", MENU_WIDTH, MENU_HEIGHT);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
void xr_menu_toggle_visible(void) {
|
|
||||||
g_visible = !g_visible;
|
|
||||||
LOGI("XR: menu quad now %s", g_visible ? "visible" : "hidden");
|
|
||||||
}
|
|
||||||
|
|
||||||
bool xr_menu_is_visible(void) { return g_visible; }
|
|
||||||
|
|
||||||
void xr_menu_get_quad_extent(float *center_x_m, float *center_y_m, float *distance_m,
|
|
||||||
float *half_width_m, float *half_height_m) {
|
|
||||||
*center_x_m = MENU_CENTER_X_METERS;
|
|
||||||
*center_y_m = MENU_CENTER_Y_METERS;
|
|
||||||
*distance_m = MENU_DISTANCE_METERS;
|
|
||||||
*half_width_m = MENU_WIDTH_METERS * 0.5f;
|
|
||||||
*half_height_m = MENU_WIDTH_METERS * 0.5f * (float)MENU_HEIGHT / (float)MENU_WIDTH;
|
|
||||||
}
|
|
||||||
|
|
||||||
void xr_menu_touch(float u, float v, bool down) {
|
|
||||||
if (g_menu_overlay_class == NULL)
|
|
||||||
return;
|
|
||||||
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
|
|
||||||
if (env == NULL)
|
|
||||||
return;
|
|
||||||
// Passed via CallStaticVoidMethodA/jvalue rather than the variadic
|
|
||||||
// Call*Method form - deliberately sidesteps relying on JNI
|
|
||||||
// implementations correctly un-doing C's float-to-double default
|
|
||||||
// argument promotion for varargs float parameters (a well-known JNI
|
|
||||||
// footgun; Android's ART handles it correctly, but there's no reason
|
|
||||||
// to depend on that when the jvalue form is unambiguous either way).
|
|
||||||
jvalue args[3];
|
|
||||||
args[0].f = u;
|
|
||||||
args[1].f = v;
|
|
||||||
args[2].z = (jboolean)down;
|
|
||||||
(*env)->CallStaticVoidMethodA(env, g_menu_overlay_class, g_dispatch_touch_method, args);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pulls MenuOverlay's latest pixels (if it redrew since the last check)
|
|
||||||
// into g_pixel_cache and bumps g_pixel_generation - called once per visible
|
|
||||||
// frame, before deciding whether g_source_texture needs a fresh upload.
|
|
||||||
static void xr_menu_refresh_pixel_cache(void) {
|
|
||||||
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
|
|
||||||
if (env == NULL)
|
|
||||||
return;
|
|
||||||
jbyteArray pixels =
|
|
||||||
(jbyteArray)(*env)->CallStaticObjectMethod(env, g_menu_overlay_class, g_take_pixels_method);
|
|
||||||
// CallStaticObjectMethod also doesn't surface Java exceptions on its
|
|
||||||
// own - if nativeTakePixelsIfDirty() itself throws, it would otherwise
|
|
||||||
// silently look identical to "nothing changed yet" (a null return).
|
|
||||||
if ((*env)->ExceptionCheck(env)) {
|
|
||||||
LOGE("XR: menu nativeTakePixelsIfDirty() threw a pending Java exception:");
|
|
||||||
(*env)->ExceptionDescribe(env);
|
|
||||||
(*env)->ExceptionClear(env);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (pixels == NULL)
|
|
||||||
return;
|
|
||||||
(*env)->GetByteArrayRegion(env, pixels, 0, MENU_WIDTH * MENU_HEIGHT * 4, (jbyte *)g_pixel_cache);
|
|
||||||
(*env)->DeleteLocalRef(env, pixels);
|
|
||||||
g_pixel_generation++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Uploads g_pixel_cache into g_source_texture via plain (gl4es-routed)
|
|
||||||
// glBindTexture/glTexSubImage2D, matching how the texture was created.
|
|
||||||
// Skipped when nothing changed since the last upload. Texture-unit-0 state
|
|
||||||
// is saved and restored around the upload, since this call is gl4es-routed
|
|
||||||
// and the engine's own next-frame rendering assumes nothing touched its
|
|
||||||
// texture bindings since it last drew.
|
|
||||||
static void xr_menu_upload_source_texture_if_dirty(void) {
|
|
||||||
if (g_source_uploaded_generation == g_pixel_generation)
|
|
||||||
return;
|
|
||||||
|
|
||||||
GLint prevActiveTexture = GL_TEXTURE0;
|
|
||||||
glGetIntegerv(GL_ACTIVE_TEXTURE, &prevActiveTexture);
|
|
||||||
glActiveTexture(GL_TEXTURE0);
|
|
||||||
GLint prevTexture2D = 0;
|
|
||||||
glGetIntegerv(GL_TEXTURE_BINDING_2D, &prevTexture2D);
|
|
||||||
|
|
||||||
glBindTexture(GL_TEXTURE_2D, g_source_texture);
|
|
||||||
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, MENU_WIDTH, MENU_HEIGHT, GL_RGBA, GL_UNSIGNED_BYTE,
|
|
||||||
g_pixel_cache);
|
|
||||||
g_source_uploaded_generation = g_pixel_generation;
|
|
||||||
|
|
||||||
glBindTexture(GL_TEXTURE_2D, (GLuint)prevTexture2D);
|
|
||||||
if (prevActiveTexture != GL_TEXTURE0)
|
|
||||||
glActiveTexture((GLenum)prevActiveTexture);
|
|
||||||
}
|
|
||||||
|
|
||||||
void xr_menu_render_if_visible(void) {
|
|
||||||
if (g_source_fbo == 0)
|
|
||||||
return;
|
|
||||||
|
|
||||||
xr_menu_refresh_pixel_cache();
|
|
||||||
xr_menu_upload_source_texture_if_dirty();
|
|
||||||
|
|
||||||
// Copies g_source_texture (via g_source_fbo) directly into the menu's
|
|
||||||
// sub-rectangle of the currently-bound shared swapchain framebuffer,
|
|
||||||
// using the real GLES3 hardware blit (glBlitFramebuffer) rather than a
|
|
||||||
// shader-based full-screen-quad draw. A blit has no vertex/fragment
|
|
||||||
// shading stage, no shader program, and no texture units involved at
|
|
||||||
// all, so gl4es's fixed-pipeline-emulation layer - which unconditionally
|
|
||||||
// substitutes a customized shader (reproducing the game's own
|
|
||||||
// last-bound texture/fixed-function state) onto any gl4es-routed draw
|
|
||||||
// call - has nothing to intercept here. It also never touches gl4es's
|
|
||||||
// own tracked program/vertex-array/texture-binding shadow state, so the
|
|
||||||
// only piece of state that needs save/restore is the READ framebuffer
|
|
||||||
// binding; binding only GL_READ_FRAMEBUFFER, rather than the combined
|
|
||||||
// GL_FRAMEBUFFER target, leaves the DRAW side (the swapchain image
|
|
||||||
// itself) untouched throughout.
|
|
||||||
//
|
|
||||||
// Y is flipped between source and destination: g_source_texture's texel
|
|
||||||
// row v=0 holds MenuOverlay's Bitmap row 0 (the top of the rendered
|
|
||||||
// content, standard top-row-first raster order), and reading that same
|
|
||||||
// texture via an attached FBO, framebuffer y=0 accesses that identical
|
|
||||||
// row. The destination swapchain framebuffer follows the opposite
|
|
||||||
// convention - its own y=0 is its bottom edge - so swapping dstY0/dstY1
|
|
||||||
// keeps the menu content right-side up (glBlitFramebuffer supports
|
|
||||||
// inverted src/dst rects for exactly this).
|
|
||||||
GLint prevReadFbo = 0;
|
|
||||||
real_glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &prevReadFbo);
|
|
||||||
GLint viewport[4] = {0, 0, 0, 0};
|
|
||||||
real_glGetIntegerv(GL_VIEWPORT, viewport);
|
|
||||||
|
|
||||||
real_glBindFramebuffer(GL_READ_FRAMEBUFFER, g_source_fbo);
|
|
||||||
real_glBlitFramebuffer(0, 0, MENU_WIDTH, MENU_HEIGHT, viewport[0], viewport[1] + viewport[3],
|
|
||||||
viewport[0] + viewport[2], viewport[1], GL_COLOR_BUFFER_BIT,
|
|
||||||
GL_LINEAR);
|
|
||||||
real_glBindFramebuffer(GL_READ_FRAMEBUFFER, (GLuint)prevReadFbo);
|
|
||||||
}
|
|
||||||
|
|
||||||
void xr_menu_shutdown(void) {
|
|
||||||
if (g_source_texture != 0)
|
|
||||||
glDeleteTextures(1, &g_source_texture);
|
|
||||||
g_source_texture = 0;
|
|
||||||
g_source_uploaded_generation = (uint32_t)-1;
|
|
||||||
|
|
||||||
if (g_source_fbo != 0 && real_glDeleteFramebuffers != NULL)
|
|
||||||
real_glDeleteFramebuffers(1, &g_source_fbo);
|
|
||||||
g_source_fbo = 0;
|
|
||||||
|
|
||||||
if (g_menu_overlay_class != NULL) {
|
|
||||||
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
|
|
||||||
if (env != NULL)
|
|
||||||
(*env)->DeleteGlobalRef(env, g_menu_overlay_class);
|
|
||||||
}
|
|
||||||
g_menu_overlay_class = NULL;
|
|
||||||
g_native_init_method = NULL;
|
|
||||||
g_take_pixels_method = NULL;
|
|
||||||
g_dispatch_touch_method = NULL;
|
|
||||||
|
|
||||||
free(g_pixel_cache);
|
|
||||||
g_pixel_cache = NULL;
|
|
||||||
g_pixel_generation = 0;
|
|
||||||
|
|
||||||
g_visible = false;
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
// Menu quad for questshock's immersive Quest build: MenuOverlay.java's
|
|
||||||
// off-screen-rendered View tree (currently just a "Keyboard" button - see
|
|
||||||
// the plan's step D for the on-screen keyboard that goes behind it),
|
|
||||||
// toggled by xr_input.c's menu_toggle action and hit-tested by the same ray
|
|
||||||
// xr_input.c already computes per hand.
|
|
||||||
//
|
|
||||||
// The menu's content shares xr_session.c's single OpenXR swapchain (see
|
|
||||||
// that file for why: two independently-created swapchains submitted as
|
|
||||||
// separate composition layers make the Horizon OS compositor show the
|
|
||||||
// game's own content on the menu quad instead of the menu's - a
|
|
||||||
// compositor-level limitation, not anything under this app's control).
|
|
||||||
// xr_session.c sizes its shared swapchain to fit both the game's and the
|
|
||||||
// menu's content side by side, and calls into this file to render the
|
|
||||||
// menu's own sub-rectangle each visible frame.
|
|
||||||
#ifndef QUESTSHOCK_XR_MENU_H
|
|
||||||
#define QUESTSHOCK_XR_MENU_H
|
|
||||||
|
|
||||||
#include <stdbool.h>
|
|
||||||
|
|
||||||
#define XR_USE_PLATFORM_ANDROID 1
|
|
||||||
#define XR_USE_GRAPHICS_API_OPENGL_ES 1
|
|
||||||
#include <openxr/openxr.h>
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
extern "C" {
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// Pixel size of the menu's own content region - xr_session.c needs this
|
|
||||||
// before it creates its shared swapchain, to size it to fit both the
|
|
||||||
// game's and the menu's content. Has no OpenXR/GL dependency, safe to call
|
|
||||||
// before xr_menu_init().
|
|
||||||
void xr_menu_get_content_size(int *width, int *height);
|
|
||||||
|
|
||||||
// Call once, after xr_session.c has created its shared swapchain - sets up
|
|
||||||
// the MenuOverlay Java-side singleton and the gl4es-owned texture its
|
|
||||||
// pixels get uploaded into. Returns false (logged, non-fatal - the caller
|
|
||||||
// just has no menu) on failure.
|
|
||||||
bool xr_menu_init(XrInstance instance, XrSession session);
|
|
||||||
|
|
||||||
// Flips menu-quad visibility - called by xr_input.c on the menu_toggle
|
|
||||||
// action's rising edge.
|
|
||||||
void xr_menu_toggle_visible(void);
|
|
||||||
bool xr_menu_is_visible(void);
|
|
||||||
|
|
||||||
// Local-space pose/size of the menu quad, valid regardless of visibility -
|
|
||||||
// shared with xr_input.c's ray/quad hit-testing, the same way
|
|
||||||
// xr_get_game_quad_extent() (xr_session.h) is for the game quad.
|
|
||||||
void xr_menu_get_quad_extent(float *center_x_m, float *center_y_m, float *distance_m,
|
|
||||||
float *half_width_m, float *half_height_m);
|
|
||||||
|
|
||||||
// Forwards a hit on the menu quad (in the quad's own 0..1 u/v, top-left
|
|
||||||
// origin) to the MenuOverlay Java view as a synthetic touch down/up -
|
|
||||||
// called by xr_input.c on the select_click action's edges, only while a
|
|
||||||
// hand's aim ray currently hits the menu quad.
|
|
||||||
void xr_menu_touch(float u, float v, bool down);
|
|
||||||
|
|
||||||
// Refreshes MenuOverlay's pixels from Java if they changed since the last
|
|
||||||
// call, and blits them into whichever framebuffer is currently bound -
|
|
||||||
// xr_session.c binds its shared swapchain image and sets the
|
|
||||||
// viewport/scissor to the menu's own sub-rectangle within it before calling
|
|
||||||
// this. Only called while xr_menu_is_visible().
|
|
||||||
void xr_menu_render_if_visible(void);
|
|
||||||
|
|
||||||
void xr_menu_shutdown(void);
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#endif
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#include "xr_mouse.h"
|
||||||
|
|
||||||
|
#include <SDL.h>
|
||||||
|
|
||||||
|
#include "mouse.h" // engine/src/Libraries/INPUT/Source/mouse.h - mouse_put_xy()
|
||||||
|
|
||||||
|
void xr_mouse_move(int x, int y) { mouse_put_xy((short)x, (short)y); }
|
||||||
|
|
||||||
|
void xr_mouse_click(bool down) {
|
||||||
|
SDL_Event event;
|
||||||
|
SDL_zero(event);
|
||||||
|
event.type = down ? SDL_MOUSEBUTTONDOWN : SDL_MOUSEBUTTONUP;
|
||||||
|
event.button.timestamp = SDL_GetTicks();
|
||||||
|
event.button.windowID = 0;
|
||||||
|
event.button.which = 0;
|
||||||
|
event.button.button = SDL_BUTTON_LEFT;
|
||||||
|
event.button.state = down ? SDL_PRESSED : SDL_RELEASED;
|
||||||
|
event.button.clicks = 1;
|
||||||
|
SDL_PushEvent(&event);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// Bridges VR aim-ray hit-testing (xr_input.c) to the vendored engine's
|
||||||
|
// mouse input (engine/src/Libraries/INPUT/Source/mouse.h) - lets the
|
||||||
|
// controller's laser pointer drive the game's own UI (inventory, menus,
|
||||||
|
// dialogs) exactly like a desktop mouse: absolute cursor position plus a
|
||||||
|
// single left button, no relative/mouselook mode, no right button.
|
||||||
|
#ifndef QUESTSHOCK_XR_MOUSE_H
|
||||||
|
#define QUESTSHOCK_XR_MOUSE_H
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Sets the engine's absolute mouse position (mouse_put_xy()) - call every
|
||||||
|
// frame the aim ray is actually hitting the game quad; x/y are pixel
|
||||||
|
// coordinates in the game's own logical resolution (see
|
||||||
|
// xr_get_game_resolution() in xr_session.h), already clamped by the
|
||||||
|
// caller. Do not call on frames the ray isn't hitting the game quad - the
|
||||||
|
// cursor should hold its last position, not snap elsewhere.
|
||||||
|
void xr_mouse_move(int x, int y);
|
||||||
|
|
||||||
|
// Pushes a synthetic SDL_MOUSEBUTTONDOWN/UP (SDL_BUTTON_LEFT) - matches
|
||||||
|
// KeyboardOverlay's nativeSendPrintableCharDown()/Up() in
|
||||||
|
// questshock_native.c: a plain SDL_Event built by hand and handed to the
|
||||||
|
// public SDL_PushEvent(),
|
||||||
|
// no engine patch needed. pump_events() (sdl_events.c) reads whatever
|
||||||
|
// mouse_put_xy() last set, not any x/y carried on the event itself - call
|
||||||
|
// xr_mouse_move() first if position needs updating this frame.
|
||||||
|
void xr_mouse_click(bool down);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,503 @@
|
|||||||
|
#include "xr_overlay.h"
|
||||||
|
|
||||||
|
#include <dlfcn.h>
|
||||||
|
#include <math.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include <android/log.h>
|
||||||
|
#include <jni.h>
|
||||||
|
|
||||||
|
#include <EGL/egl.h>
|
||||||
|
#include <GLES3/gl3.h>
|
||||||
|
|
||||||
|
#include <SDL.h>
|
||||||
|
|
||||||
|
#include "xr_swapchain.h"
|
||||||
|
|
||||||
|
#define TAG "QuestShock"
|
||||||
|
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__)
|
||||||
|
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
|
||||||
|
|
||||||
|
// Same constant (deliberately not <math.h>'s M_PI, which isn't used
|
||||||
|
// anywhere else in this codebase and isn't guaranteed by the C standard -
|
||||||
|
// only POSIX) as xr_session.c's own play-space rebuild math.
|
||||||
|
#define QS_DEG_TO_RAD (3.14159265358979323846f / 180.0f)
|
||||||
|
|
||||||
|
struct XrOverlay {
|
||||||
|
XrInstance instance; // for error logging only
|
||||||
|
int width, height;
|
||||||
|
float distance_m, half_width_m, half_height_m;
|
||||||
|
float center_x_m, center_y_m;
|
||||||
|
// The *authored* distance_m (config->distance_m at creation time),
|
||||||
|
// never touched again afterward by anything, including
|
||||||
|
// xr_overlay_set_pose() - kept separate from the live, mutable
|
||||||
|
// distance_m above because that one stops meaning "how far in front of
|
||||||
|
// the origin" once a rotated set_pose() call (the menu recenter)
|
||||||
|
// repurposes it as a raw base-space Z coordinate; reusing *that* as a
|
||||||
|
// "how far in front of me" magnitude on a later recenter would drift
|
||||||
|
// further with every reopen instead of staying at a stable distance -
|
||||||
|
// see xr_overlay_get_default_distance_m().
|
||||||
|
float default_distance_m;
|
||||||
|
// Rotation about the vertical axis, applied on top of center_x_m/
|
||||||
|
// center_y_m/distance_m (see xr_overlay_set_pose()) - 0 for every
|
||||||
|
// overlay except the menu launcher immediately after it's freshly
|
||||||
|
// reopened (xr_input.c re-centers it in front of wherever the player
|
||||||
|
// is currently facing, which needs this to keep the quad actually
|
||||||
|
// facing them instead of whatever direction it started out at).
|
||||||
|
float yaw_deg;
|
||||||
|
bool visible;
|
||||||
|
|
||||||
|
XrSwapchainState swapchain;
|
||||||
|
|
||||||
|
jclass java_class;
|
||||||
|
jmethodID native_init_method;
|
||||||
|
jmethodID take_pixels_method;
|
||||||
|
jmethodID dispatch_touch_method;
|
||||||
|
jmethodID update_cursor_method;
|
||||||
|
|
||||||
|
// This overlay's content lives in this texture, uploaded by
|
||||||
|
// xr_overlay_upload_source_texture_if_dirty() below. It's created
|
||||||
|
// through gl4es (glGenTextures/glBindTexture/glTexImage2D), like any
|
||||||
|
// other texture the engine itself creates, so ordinary
|
||||||
|
// glTexSubImage2D uploads against it work the normal way.
|
||||||
|
GLuint source_texture;
|
||||||
|
// A framebuffer with source_texture as its only color attachment, used
|
||||||
|
// as the read source for the blit in
|
||||||
|
// xr_overlay_render_and_build_layer() below. Created via the real
|
||||||
|
// (non-gl4es) GLES entry points below: that blit bypasses gl4es
|
||||||
|
// entirely (see xr_overlay_render_and_build_layer() for why), so its
|
||||||
|
// source framebuffer has to be a real GL object rather than one gl4es
|
||||||
|
// tracks.
|
||||||
|
GLuint source_fbo;
|
||||||
|
|
||||||
|
uint32_t pixel_generation;
|
||||||
|
uint32_t source_uploaded_generation;
|
||||||
|
uint8_t *pixel_cache; // width*height*4 bytes
|
||||||
|
};
|
||||||
|
|
||||||
|
// This file's other GL calls otherwise resolve to gl4es (the only GL
|
||||||
|
// symbol provider linked into this binary - see android/engine-patches/
|
||||||
|
// 02-android-opengl-es.patch), fine for anything shared with the engine's
|
||||||
|
// own gl4es-routed rendering. But the source FBO's blit (see
|
||||||
|
// xr_overlay_render_and_build_layer()) needs the real driver directly:
|
||||||
|
// gl4es's fixed-pipeline emulation unconditionally substitutes its own
|
||||||
|
// shader onto any gl4es-routed draw call, which would silently replace an
|
||||||
|
// overlay's content with whatever the game itself last rendered. A
|
||||||
|
// framebuffer blit has no shader stage at all, so going through the real
|
||||||
|
// driver for it sidesteps the problem entirely - resolved once here and
|
||||||
|
// shared by every XrOverlay instance, since they're process-wide function
|
||||||
|
// pointers regardless of how many overlays exist.
|
||||||
|
typedef void (*PFNQSGETINTEGERV)(GLenum, GLint *);
|
||||||
|
typedef void (*PFNQSGENFRAMEBUFFERS)(GLsizei, GLuint *);
|
||||||
|
typedef void (*PFNQSDELETEFRAMEBUFFERS)(GLsizei, const GLuint *);
|
||||||
|
typedef void (*PFNQSBINDFRAMEBUFFER)(GLenum, GLuint);
|
||||||
|
typedef void (*PFNQSFRAMEBUFFERTEXTURE2D)(GLenum, GLenum, GLenum, GLuint, GLint);
|
||||||
|
typedef void (*PFNQSBLITFRAMEBUFFER)(GLint, GLint, GLint, GLint, GLint, GLint, GLint, GLint,
|
||||||
|
GLbitfield, GLenum);
|
||||||
|
|
||||||
|
static PFNQSGETINTEGERV real_glGetIntegerv;
|
||||||
|
static PFNQSGENFRAMEBUFFERS real_glGenFramebuffers;
|
||||||
|
static PFNQSDELETEFRAMEBUFFERS real_glDeleteFramebuffers;
|
||||||
|
static PFNQSBINDFRAMEBUFFER real_glBindFramebuffer;
|
||||||
|
static PFNQSFRAMEBUFFERTEXTURE2D real_glFramebufferTexture2D;
|
||||||
|
// glBlitFramebuffer is a GLES 3.0 addition; resolved via eglGetProcAddress
|
||||||
|
// rather than dlsym, since Android's driver dispatch doesn't guarantee ES3+
|
||||||
|
// symbols are dlsym-able by name from libGLESv2.so, unlike the GLES2-core
|
||||||
|
// functions above.
|
||||||
|
static PFNQSBLITFRAMEBUFFER real_glBlitFramebuffer;
|
||||||
|
static bool g_real_gles_loaded = false;
|
||||||
|
|
||||||
|
static bool xr_overlay_load_real_gles(void) {
|
||||||
|
if (g_real_gles_loaded)
|
||||||
|
return true;
|
||||||
|
void *lib = dlopen("libGLESv2.so", RTLD_NOW | RTLD_LOCAL);
|
||||||
|
if (lib == NULL) {
|
||||||
|
LOGE("XR: overlay dlopen(libGLESv2.so) failed: %s", dlerror());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
real_glGetIntegerv = (PFNQSGETINTEGERV)dlsym(lib, "glGetIntegerv");
|
||||||
|
real_glGenFramebuffers = (PFNQSGENFRAMEBUFFERS)dlsym(lib, "glGenFramebuffers");
|
||||||
|
real_glDeleteFramebuffers = (PFNQSDELETEFRAMEBUFFERS)dlsym(lib, "glDeleteFramebuffers");
|
||||||
|
real_glBindFramebuffer = (PFNQSBINDFRAMEBUFFER)dlsym(lib, "glBindFramebuffer");
|
||||||
|
real_glFramebufferTexture2D = (PFNQSFRAMEBUFFERTEXTURE2D)dlsym(lib, "glFramebufferTexture2D");
|
||||||
|
real_glBlitFramebuffer = (PFNQSBLITFRAMEBUFFER)eglGetProcAddress("glBlitFramebuffer");
|
||||||
|
g_real_gles_loaded = real_glGetIntegerv && real_glGenFramebuffers &&
|
||||||
|
real_glDeleteFramebuffers && real_glBindFramebuffer &&
|
||||||
|
real_glFramebufferTexture2D && real_glBlitFramebuffer;
|
||||||
|
return g_real_gles_loaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindClass() from this thread (SDL's native thread, attached to the JVM
|
||||||
|
// via AttachCurrentThread rather than spawned from Java) resolves against
|
||||||
|
// the bootstrap classloader, which only knows framework classes - it can't
|
||||||
|
// see app classes like de.ladkau.questshock.KeyboardOverlay at all.
|
||||||
|
// Routing through the activity's own classloader is the standard,
|
||||||
|
// documented workaround.
|
||||||
|
static jclass xr_overlay_find_class(JNIEnv *env, jobject activity, const char *name) {
|
||||||
|
jclass activityClass = (*env)->GetObjectClass(env, activity);
|
||||||
|
jmethodID getClassLoader =
|
||||||
|
(*env)->GetMethodID(env, activityClass, "getClassLoader", "()Ljava/lang/ClassLoader;");
|
||||||
|
jobject classLoader = (*env)->CallObjectMethod(env, activity, getClassLoader);
|
||||||
|
jclass classLoaderClass = (*env)->FindClass(env, "java/lang/ClassLoader");
|
||||||
|
jmethodID loadClass = (*env)->GetMethodID(env, classLoaderClass, "loadClass",
|
||||||
|
"(Ljava/lang/String;)Ljava/lang/Class;");
|
||||||
|
jstring className = (*env)->NewStringUTF(env, name);
|
||||||
|
jclass result = (jclass)(*env)->CallObjectMethod(env, classLoader, loadClass, className);
|
||||||
|
(*env)->DeleteLocalRef(env, className);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool xr_overlay_init_jni(XrOverlay *overlay, const char *java_class_name) {
|
||||||
|
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
|
||||||
|
jobject activity = (jobject)SDL_AndroidGetActivity();
|
||||||
|
if (env == NULL || activity == NULL) {
|
||||||
|
LOGE("XR: overlay - no JNIEnv/Activity from SDL");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
jclass localClass = xr_overlay_find_class(env, activity, java_class_name);
|
||||||
|
if (localClass == NULL) {
|
||||||
|
LOGE("XR: overlay - could not find class %s", java_class_name);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
overlay->java_class = (jclass)(*env)->NewGlobalRef(env, localClass);
|
||||||
|
|
||||||
|
overlay->native_init_method = (*env)->GetStaticMethodID(env, overlay->java_class, "nativeInit",
|
||||||
|
"(Landroid/app/Activity;)V");
|
||||||
|
overlay->take_pixels_method =
|
||||||
|
(*env)->GetStaticMethodID(env, overlay->java_class, "nativeTakePixelsIfDirty", "()[B");
|
||||||
|
overlay->dispatch_touch_method =
|
||||||
|
(*env)->GetStaticMethodID(env, overlay->java_class, "nativeDispatchTouch", "(FFZ)V");
|
||||||
|
overlay->update_cursor_method =
|
||||||
|
(*env)->GetStaticMethodID(env, overlay->java_class, "nativeUpdateCursor", "(FFZ)V");
|
||||||
|
if (!overlay->native_init_method || !overlay->take_pixels_method ||
|
||||||
|
!overlay->dispatch_touch_method || !overlay->update_cursor_method) {
|
||||||
|
LOGE("XR: overlay - could not resolve %s JNI methods", java_class_name);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
(*env)->CallStaticVoidMethod(env, overlay->java_class, overlay->native_init_method, activity);
|
||||||
|
// CallStaticVoidMethod doesn't surface Java exceptions on its own - if
|
||||||
|
// the constructor throws (it runs its View measure/layout/draw calls
|
||||||
|
// on this native render thread rather than the Android UI thread, a
|
||||||
|
// plausible crash vector), the exception would otherwise be left
|
||||||
|
// silently pending and corrupt whatever JNI call runs next.
|
||||||
|
if ((*env)->ExceptionCheck(env)) {
|
||||||
|
LOGE("XR: overlay %s.nativeInit() threw a pending Java exception:", java_class_name);
|
||||||
|
(*env)->ExceptionDescribe(env);
|
||||||
|
(*env)->ExceptionClear(env);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool xr_overlay_create_source_texture(XrOverlay *overlay) {
|
||||||
|
glGenTextures(1, &overlay->source_texture);
|
||||||
|
glBindTexture(GL_TEXTURE_2D, overlay->source_texture);
|
||||||
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
|
||||||
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
||||||
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
|
||||||
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
|
||||||
|
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, overlay->width, overlay->height, 0, GL_RGBA,
|
||||||
|
GL_UNSIGNED_BYTE, NULL);
|
||||||
|
|
||||||
|
GLint prevFbo = 0;
|
||||||
|
real_glGetIntegerv(GL_FRAMEBUFFER_BINDING, &prevFbo);
|
||||||
|
real_glGenFramebuffers(1, &overlay->source_fbo);
|
||||||
|
real_glBindFramebuffer(GL_FRAMEBUFFER, overlay->source_fbo);
|
||||||
|
real_glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D,
|
||||||
|
overlay->source_texture, 0);
|
||||||
|
real_glBindFramebuffer(GL_FRAMEBUFFER, (GLuint)prevFbo);
|
||||||
|
|
||||||
|
return overlay->source_texture != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
XrOverlay *xr_overlay_create(XrInstance instance, XrSession session, int64_t swapchain_format,
|
||||||
|
const XrOverlayConfig *config) {
|
||||||
|
if (!xr_overlay_load_real_gles()) {
|
||||||
|
LOGE("XR: overlay couldn't resolve real GLES functions via dlsym/eglGetProcAddress");
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
XrOverlay *overlay = (XrOverlay *)calloc(1, sizeof(XrOverlay));
|
||||||
|
if (overlay == NULL)
|
||||||
|
return NULL;
|
||||||
|
|
||||||
|
overlay->instance = instance;
|
||||||
|
overlay->width = config->width;
|
||||||
|
overlay->height = config->height;
|
||||||
|
overlay->distance_m = config->distance_m;
|
||||||
|
overlay->default_distance_m = config->distance_m;
|
||||||
|
overlay->half_width_m = config->width_m * 0.5f;
|
||||||
|
overlay->half_height_m = config->width_m * 0.5f * (float)config->height / (float)config->width;
|
||||||
|
overlay->center_x_m = config->default_center_x_m;
|
||||||
|
overlay->center_y_m = config->default_center_y_m;
|
||||||
|
|
||||||
|
if (!xr_swapchain_create(instance, session, swapchain_format, config->width, config->height,
|
||||||
|
&overlay->swapchain)) {
|
||||||
|
LOGE("XR: overlay %s swapchain setup failed", config->java_class_name);
|
||||||
|
free(overlay);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!xr_overlay_create_source_texture(overlay)) {
|
||||||
|
xr_swapchain_destroy(&overlay->swapchain);
|
||||||
|
free(overlay);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!xr_overlay_init_jni(overlay, config->java_class_name)) {
|
||||||
|
xr_swapchain_destroy(&overlay->swapchain);
|
||||||
|
free(overlay);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay->pixel_cache = (uint8_t *)malloc((size_t)config->width * config->height * 4);
|
||||||
|
if (overlay->pixel_cache == NULL) {
|
||||||
|
xr_swapchain_destroy(&overlay->swapchain);
|
||||||
|
free(overlay);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
overlay->source_uploaded_generation = (uint32_t)-1;
|
||||||
|
|
||||||
|
LOGI("XR: overlay %s ready (%dx%d)", config->java_class_name, config->width, config->height);
|
||||||
|
return overlay;
|
||||||
|
}
|
||||||
|
|
||||||
|
void xr_overlay_destroy(XrOverlay *overlay) {
|
||||||
|
if (overlay == NULL)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (overlay->source_texture != 0)
|
||||||
|
glDeleteTextures(1, &overlay->source_texture);
|
||||||
|
if (overlay->source_fbo != 0 && real_glDeleteFramebuffers != NULL)
|
||||||
|
real_glDeleteFramebuffers(1, &overlay->source_fbo);
|
||||||
|
|
||||||
|
if (overlay->java_class != NULL) {
|
||||||
|
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
|
||||||
|
if (env != NULL)
|
||||||
|
(*env)->DeleteGlobalRef(env, overlay->java_class);
|
||||||
|
}
|
||||||
|
|
||||||
|
xr_swapchain_destroy(&overlay->swapchain);
|
||||||
|
free(overlay->pixel_cache);
|
||||||
|
free(overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every function below tolerates overlay == NULL (a no-op, or the
|
||||||
|
// obvious "not visible"/zeroed-extent default) - xr_overlay_create() can
|
||||||
|
// fail (e.g. its backing Java class couldn't be resolved), and unlike the
|
||||||
|
// single static-global menu quad this replaced, an overlay instance is
|
||||||
|
// otherwise a heap pointer callers (xr_session.c, questshock_native.c) can
|
||||||
|
// hold onto and use across frames without re-checking for failure every
|
||||||
|
// time.
|
||||||
|
void xr_overlay_toggle_visible(XrOverlay *overlay) {
|
||||||
|
if (overlay != NULL)
|
||||||
|
overlay->visible = !overlay->visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
void xr_overlay_set_visible(XrOverlay *overlay, bool visible) {
|
||||||
|
if (overlay != NULL)
|
||||||
|
overlay->visible = visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool xr_overlay_is_visible(const XrOverlay *overlay) { return overlay != NULL && overlay->visible; }
|
||||||
|
|
||||||
|
void xr_overlay_get_quad_extent(const XrOverlay *overlay, float *center_x_m, float *center_y_m,
|
||||||
|
float *distance_m, float *half_width_m, float *half_height_m,
|
||||||
|
float *yaw_deg) {
|
||||||
|
if (overlay == NULL) {
|
||||||
|
*center_x_m = *center_y_m = *distance_m = *half_width_m = *half_height_m = *yaw_deg = 0.0f;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
*center_x_m = overlay->center_x_m;
|
||||||
|
*center_y_m = overlay->center_y_m;
|
||||||
|
*distance_m = overlay->distance_m;
|
||||||
|
*half_width_m = overlay->half_width_m;
|
||||||
|
*half_height_m = overlay->half_height_m;
|
||||||
|
*yaw_deg = overlay->yaw_deg;
|
||||||
|
}
|
||||||
|
|
||||||
|
void xr_overlay_set_position(XrOverlay *overlay, float center_x_m, float center_y_m) {
|
||||||
|
if (overlay == NULL)
|
||||||
|
return;
|
||||||
|
overlay->center_x_m = center_x_m;
|
||||||
|
overlay->center_y_m = center_y_m;
|
||||||
|
}
|
||||||
|
|
||||||
|
void xr_overlay_set_pose(XrOverlay *overlay, float center_x_m, float center_y_m, float distance_m,
|
||||||
|
float yaw_deg) {
|
||||||
|
if (overlay == NULL)
|
||||||
|
return;
|
||||||
|
overlay->center_x_m = center_x_m;
|
||||||
|
overlay->center_y_m = center_y_m;
|
||||||
|
overlay->distance_m = distance_m;
|
||||||
|
overlay->yaw_deg = yaw_deg;
|
||||||
|
}
|
||||||
|
|
||||||
|
float xr_overlay_get_default_distance_m(const XrOverlay *overlay) {
|
||||||
|
return overlay == NULL ? 0.0f : overlay->default_distance_m;
|
||||||
|
}
|
||||||
|
|
||||||
|
void xr_overlay_touch(XrOverlay *overlay, float u, float v, bool down) {
|
||||||
|
if (overlay == NULL)
|
||||||
|
return;
|
||||||
|
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
|
||||||
|
if (env == NULL)
|
||||||
|
return;
|
||||||
|
// Passed via CallStaticVoidMethodA/jvalue rather than the variadic
|
||||||
|
// Call*Method form - deliberately sidesteps relying on JNI
|
||||||
|
// implementations correctly un-doing C's float-to-double default
|
||||||
|
// argument promotion for varargs float parameters (a well-known JNI
|
||||||
|
// footgun; Android's ART handles it correctly, but there's no reason
|
||||||
|
// to depend on that when the jvalue form is unambiguous either way).
|
||||||
|
jvalue args[3];
|
||||||
|
args[0].f = u;
|
||||||
|
args[1].f = v;
|
||||||
|
args[2].z = (jboolean)down;
|
||||||
|
(*env)->CallStaticVoidMethodA(env, overlay->java_class, overlay->dispatch_touch_method, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
void xr_overlay_update_cursor(XrOverlay *overlay, float u, float v, bool visible) {
|
||||||
|
if (overlay == NULL)
|
||||||
|
return;
|
||||||
|
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
|
||||||
|
if (env == NULL)
|
||||||
|
return;
|
||||||
|
// Same jvalue-form rationale as xr_overlay_touch() above.
|
||||||
|
jvalue args[3];
|
||||||
|
args[0].f = u;
|
||||||
|
args[1].f = v;
|
||||||
|
args[2].z = (jboolean)visible;
|
||||||
|
(*env)->CallStaticVoidMethodA(env, overlay->java_class, overlay->update_cursor_method, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pulls the Java view's latest pixels (if it redrew since the last check)
|
||||||
|
// into pixel_cache and bumps pixel_generation - called once per rendered
|
||||||
|
// frame, before deciding whether source_texture needs a fresh upload.
|
||||||
|
static void xr_overlay_refresh_pixel_cache(XrOverlay *overlay) {
|
||||||
|
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
|
||||||
|
if (env == NULL)
|
||||||
|
return;
|
||||||
|
jbyteArray pixels = (jbyteArray)(*env)->CallStaticObjectMethod(
|
||||||
|
env, overlay->java_class, overlay->take_pixels_method);
|
||||||
|
// CallStaticObjectMethod also doesn't surface Java exceptions on its
|
||||||
|
// own - if nativeTakePixelsIfDirty() itself throws, it would otherwise
|
||||||
|
// silently look identical to "nothing changed yet" (a null return).
|
||||||
|
if ((*env)->ExceptionCheck(env)) {
|
||||||
|
LOGE("XR: overlay nativeTakePixelsIfDirty() threw a pending Java exception:");
|
||||||
|
(*env)->ExceptionDescribe(env);
|
||||||
|
(*env)->ExceptionClear(env);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pixels == NULL)
|
||||||
|
return;
|
||||||
|
(*env)->GetByteArrayRegion(env, pixels, 0, overlay->width * overlay->height * 4,
|
||||||
|
(jbyte *)overlay->pixel_cache);
|
||||||
|
(*env)->DeleteLocalRef(env, pixels);
|
||||||
|
overlay->pixel_generation++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uploads pixel_cache into source_texture via plain (gl4es-routed)
|
||||||
|
// glBindTexture/glTexSubImage2D, matching how the texture was created.
|
||||||
|
// Skipped when nothing changed since the last upload. Texture-unit-0 state
|
||||||
|
// is saved and restored around the upload, since this call is gl4es-routed
|
||||||
|
// and the engine's own next-frame rendering assumes nothing touched its
|
||||||
|
// texture bindings since it last drew.
|
||||||
|
static void xr_overlay_upload_source_texture_if_dirty(XrOverlay *overlay) {
|
||||||
|
if (overlay->source_uploaded_generation == overlay->pixel_generation)
|
||||||
|
return;
|
||||||
|
|
||||||
|
GLint prevActiveTexture = GL_TEXTURE0;
|
||||||
|
glGetIntegerv(GL_ACTIVE_TEXTURE, &prevActiveTexture);
|
||||||
|
glActiveTexture(GL_TEXTURE0);
|
||||||
|
GLint prevTexture2D = 0;
|
||||||
|
glGetIntegerv(GL_TEXTURE_BINDING_2D, &prevTexture2D);
|
||||||
|
|
||||||
|
glBindTexture(GL_TEXTURE_2D, overlay->source_texture);
|
||||||
|
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, overlay->width, overlay->height, GL_RGBA,
|
||||||
|
GL_UNSIGNED_BYTE, overlay->pixel_cache);
|
||||||
|
overlay->source_uploaded_generation = overlay->pixel_generation;
|
||||||
|
|
||||||
|
glBindTexture(GL_TEXTURE_2D, (GLuint)prevTexture2D);
|
||||||
|
if (prevActiveTexture != GL_TEXTURE0)
|
||||||
|
glActiveTexture((GLenum)prevActiveTexture);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool xr_overlay_render_and_build_layer(XrOverlay *overlay, XrCompositionLayerQuad *out_quad) {
|
||||||
|
if (overlay == NULL || !overlay->visible)
|
||||||
|
return false;
|
||||||
|
if (!xr_swapchain_acquire(overlay->instance, &overlay->swapchain))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
xr_overlay_refresh_pixel_cache(overlay);
|
||||||
|
xr_overlay_upload_source_texture_if_dirty(overlay);
|
||||||
|
|
||||||
|
// Copies source_texture (via source_fbo) directly into the swapchain
|
||||||
|
// image xr_swapchain_acquire() just bound - sized to exactly
|
||||||
|
// width x height, so the destination is always the whole image - using
|
||||||
|
// the real GLES3 hardware blit (glBlitFramebuffer) rather than a
|
||||||
|
// shader-based full-screen-quad draw. A blit has no vertex/fragment
|
||||||
|
// shading stage, no shader program, and no texture units involved at
|
||||||
|
// all, so gl4es's fixed-pipeline-emulation layer - which
|
||||||
|
// unconditionally substitutes a customized shader (reproducing the
|
||||||
|
// game's own last-bound texture/fixed-function state) onto any
|
||||||
|
// gl4es-routed draw call - has nothing to intercept here. It also
|
||||||
|
// never touches gl4es's own tracked program/vertex-array/
|
||||||
|
// texture-binding shadow state, so the only piece of state that needs
|
||||||
|
// save/restore is the READ framebuffer binding; binding only
|
||||||
|
// GL_READ_FRAMEBUFFER, rather than the combined GL_FRAMEBUFFER target,
|
||||||
|
// leaves the DRAW side (the swapchain image itself) untouched
|
||||||
|
// throughout.
|
||||||
|
//
|
||||||
|
// Y is flipped between source and destination: source_texture's texel
|
||||||
|
// row v=0 holds the Java view's Bitmap row 0 (the top of the rendered
|
||||||
|
// content, standard top-row-first raster order), and reading that same
|
||||||
|
// texture via an attached FBO, framebuffer y=0 accesses that identical
|
||||||
|
// row. The destination swapchain framebuffer follows the opposite
|
||||||
|
// convention - its own y=0 is its bottom edge - so swapping dstY0/dstY1
|
||||||
|
// keeps the content right-side up (glBlitFramebuffer supports inverted
|
||||||
|
// src/dst rects for exactly this).
|
||||||
|
GLint prevReadFbo = 0;
|
||||||
|
real_glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &prevReadFbo);
|
||||||
|
|
||||||
|
real_glBindFramebuffer(GL_READ_FRAMEBUFFER, overlay->source_fbo);
|
||||||
|
real_glBlitFramebuffer(0, 0, overlay->width, overlay->height, 0, overlay->height,
|
||||||
|
overlay->width, 0, GL_COLOR_BUFFER_BIT, GL_LINEAR);
|
||||||
|
real_glBindFramebuffer(GL_READ_FRAMEBUFFER, (GLuint)prevReadFbo);
|
||||||
|
|
||||||
|
// Without this, nothing here waits for the blit to actually finish on
|
||||||
|
// the GPU before xr_swapchain_release() hands the image back to the
|
||||||
|
// compositor - glBlitFramebuffer only queues the copy. A cross-process
|
||||||
|
// consumer reading the image before the driver has finished writing it
|
||||||
|
// is a textbook source of the diagonal-banded/torn corruption seen on
|
||||||
|
// device (see xr_swapchain_acquire()'s counterpart in OpenGL.cc, which
|
||||||
|
// had the same gap).
|
||||||
|
glFinish();
|
||||||
|
|
||||||
|
xr_swapchain_release(overlay->instance, &overlay->swapchain);
|
||||||
|
|
||||||
|
memset(out_quad, 0, sizeof(*out_quad));
|
||||||
|
out_quad->type = XR_TYPE_COMPOSITION_LAYER_QUAD;
|
||||||
|
out_quad->subImage.swapchain = overlay->swapchain.swapchain;
|
||||||
|
out_quad->subImage.imageRect.extent.width = overlay->swapchain.width;
|
||||||
|
out_quad->subImage.imageRect.extent.height = overlay->swapchain.height;
|
||||||
|
// Rotation about the vertical axis only (see the struct field's own
|
||||||
|
// comment) - same R_y(-yaw_deg) quaternion construction as
|
||||||
|
// xr_session.c's own play-space rebuild, for the same reason: this
|
||||||
|
// quad's pose is expressed as "yaw_deg turned from its own local -Z",
|
||||||
|
// so a point/ray in that rotated frame maps back into the (unrotated)
|
||||||
|
// base space by rotating by -yaw_deg.
|
||||||
|
float half = -(overlay->yaw_deg * QS_DEG_TO_RAD) * 0.5f;
|
||||||
|
out_quad->pose.orientation.x = 0.0f;
|
||||||
|
out_quad->pose.orientation.y = sinf(half);
|
||||||
|
out_quad->pose.orientation.z = 0.0f;
|
||||||
|
out_quad->pose.orientation.w = cosf(half);
|
||||||
|
out_quad->pose.position.x = overlay->center_x_m;
|
||||||
|
out_quad->pose.position.y = overlay->center_y_m;
|
||||||
|
out_quad->pose.position.z = -overlay->distance_m;
|
||||||
|
out_quad->size.width = overlay->half_width_m * 2.0f;
|
||||||
|
out_quad->size.height = overlay->half_height_m * 2.0f;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
// Generic "off-screen Android View rendered into its own OpenXR quad,
|
||||||
|
// hit-tested/touched via a laser pointer" module - each instance owns its
|
||||||
|
// own swapchain (via xr_swapchain.h), its own visible/position state, and
|
||||||
|
// the JNI glue to a backing Java class (menu launcher, keyboard, or any
|
||||||
|
// future such panel) that must expose these static methods, matching the
|
||||||
|
// pattern MenuOverlay.java already established:
|
||||||
|
// static void nativeInit(Activity activity)
|
||||||
|
// static byte[] nativeTakePixelsIfDirty()
|
||||||
|
// static void nativeDispatchTouch(float u, float v, boolean down)
|
||||||
|
// static void nativeUpdateCursor(float u, float v, boolean visible)
|
||||||
|
//
|
||||||
|
// xr_input.c drives touch/cursor dispatch and (for panels that support it,
|
||||||
|
// like the keyboard's title bar) dragging via xr_overlay_set_position();
|
||||||
|
// xr_session.c owns instance lifetime and calls
|
||||||
|
// xr_overlay_render_and_build_layer() once per frame per instance.
|
||||||
|
#ifndef QUESTSHOCK_XR_OVERLAY_H
|
||||||
|
#define QUESTSHOCK_XR_OVERLAY_H
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#define XR_USE_PLATFORM_ANDROID 1
|
||||||
|
#define XR_USE_GRAPHICS_API_OPENGL_ES 1
|
||||||
|
#include <openxr/openxr.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
typedef struct XrOverlay XrOverlay;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
// Fully-qualified Java class name backing this overlay, e.g.
|
||||||
|
// "de.ladkau.questshock.KeyboardOverlay" - resolved via the activity's
|
||||||
|
// own ClassLoader (see xr_overlay_find_class() in xr_overlay.c).
|
||||||
|
const char *java_class_name;
|
||||||
|
// Pixel size of the overlay's own content/swapchain.
|
||||||
|
int width;
|
||||||
|
int height;
|
||||||
|
// Initial local-space pose (meters, in front of the viewer along -Z) -
|
||||||
|
// see xr_overlay_get_quad_extent(). width_m is the quad's physical
|
||||||
|
// width; its height is derived to preserve width/height's aspect.
|
||||||
|
float distance_m;
|
||||||
|
float width_m;
|
||||||
|
float default_center_x_m;
|
||||||
|
float default_center_y_m;
|
||||||
|
} XrOverlayConfig;
|
||||||
|
|
||||||
|
// instance/session are used to create this overlay's own swapchain
|
||||||
|
// (format shared with every other quad this app submits - see
|
||||||
|
// xr_session.c's chosenFormat). Returns NULL (logged) on failure - the
|
||||||
|
// caller just has no such panel.
|
||||||
|
XrOverlay *xr_overlay_create(XrInstance instance, XrSession session, int64_t swapchain_format,
|
||||||
|
const XrOverlayConfig *config);
|
||||||
|
void xr_overlay_destroy(XrOverlay *overlay);
|
||||||
|
|
||||||
|
void xr_overlay_toggle_visible(XrOverlay *overlay);
|
||||||
|
void xr_overlay_set_visible(XrOverlay *overlay, bool visible);
|
||||||
|
bool xr_overlay_is_visible(const XrOverlay *overlay);
|
||||||
|
|
||||||
|
// Base-space pose/size of the quad, valid regardless of visibility - shared
|
||||||
|
// with xr_input.c's ray/quad hit-testing. yaw_deg is rotation about the
|
||||||
|
// vertical axis (see xr_overlay_set_pose()) - 0 unless something has
|
||||||
|
// called that.
|
||||||
|
void xr_overlay_get_quad_extent(const XrOverlay *overlay, float *center_x_m, float *center_y_m,
|
||||||
|
float *distance_m, float *half_width_m, float *half_height_m,
|
||||||
|
float *yaw_deg);
|
||||||
|
|
||||||
|
// Repositions the quad (base-space X/Y offset, same units/space as
|
||||||
|
// xr_overlay_get_quad_extent()'s center_x_m/center_y_m) - e.g. driven by
|
||||||
|
// xr_input.c while the user drags a panel's title bar. Distance from the
|
||||||
|
// viewer and rotation aren't adjustable this way - only left/right/up/down
|
||||||
|
// repositioning, not push/pull or turning - see xr_overlay_set_pose() for
|
||||||
|
// that.
|
||||||
|
void xr_overlay_set_position(XrOverlay *overlay, float center_x_m, float center_y_m);
|
||||||
|
|
||||||
|
// Full reposition: X/Y offset, distance ("push/pull"), and rotation about
|
||||||
|
// the vertical axis (degrees, same sign convention as
|
||||||
|
// xr_session_set_player_transform()'s yaw_deg) - used by xr_input.c to
|
||||||
|
// re-center the menu launcher in front of wherever the player currently
|
||||||
|
// is/facing each time it's freshly reopened, unlike
|
||||||
|
// xr_overlay_set_position()'s drag-only X/Y-at-fixed-distance-and-facing.
|
||||||
|
void xr_overlay_set_pose(XrOverlay *overlay, float center_x_m, float center_y_m, float distance_m,
|
||||||
|
float yaw_deg);
|
||||||
|
|
||||||
|
// The overlay's original, never-mutated distance_m (from XrOverlayConfig at
|
||||||
|
// creation time) - unlike xr_overlay_get_quad_extent()'s live distance_m,
|
||||||
|
// which xr_overlay_set_pose() repurposes as a raw base-space Z coordinate
|
||||||
|
// once any rotation is involved (see xr_overlay_set_pose()'s own comment),
|
||||||
|
// this stays a stable "how far in front" magnitude across repeated
|
||||||
|
// recenters. 0 for a NULL overlay.
|
||||||
|
float xr_overlay_get_default_distance_m(const XrOverlay *overlay);
|
||||||
|
|
||||||
|
// Forwards a hit on the quad (in the quad's own 0..1 u/v, top-left origin)
|
||||||
|
// to the backing Java view as a synthetic touch down/up.
|
||||||
|
void xr_overlay_touch(XrOverlay *overlay, float u, float v, bool down);
|
||||||
|
|
||||||
|
// Updates the on-quad cursor the Java view draws at the current aim hit
|
||||||
|
// point (same u/v convention as xr_overlay_touch()) - called once per
|
||||||
|
// frame regardless of click state, so the cursor tracks the ray
|
||||||
|
// continuously. visible=false hides it.
|
||||||
|
void xr_overlay_update_cursor(XrOverlay *overlay, float u, float v, bool visible);
|
||||||
|
|
||||||
|
// If visible: acquires this overlay's next swapchain image, blits the
|
||||||
|
// Java view's latest rendered pixels into it, releases the image, and
|
||||||
|
// fills *out_quad's subImage/pose/size. space/eyeVisibility/layerFlags are
|
||||||
|
// left for the caller to set (shared/policy choices across every layer
|
||||||
|
// this app submits, e.g. translucency - not this module's concern).
|
||||||
|
// Returns false (out_quad untouched) if not visible or something failed -
|
||||||
|
// the caller should skip submitting a layer for it this frame.
|
||||||
|
bool xr_overlay_render_and_build_layer(XrOverlay *overlay, XrCompositionLayerQuad *out_quad);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
#include "xr_session.h"
|
#include "xr_session.h"
|
||||||
|
|
||||||
#include <dlfcn.h>
|
#include <ctype.h>
|
||||||
|
#include <math.h>
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
|
||||||
@@ -18,29 +19,83 @@
|
|||||||
#include <SDL.h>
|
#include <SDL.h>
|
||||||
|
|
||||||
#include "xr_input.h"
|
#include "xr_input.h"
|
||||||
#include "xr_menu.h"
|
#include "xr_overlay.h"
|
||||||
|
#include "xr_swapchain.h"
|
||||||
|
|
||||||
#define TAG "QuestShock"
|
#define TAG "QuestShock"
|
||||||
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__)
|
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__)
|
||||||
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
|
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
|
||||||
|
|
||||||
// Fixed pose/size for the single game quad - 2m in front of the local space
|
// Original, pre-screen-profile-feature default distance for the game
|
||||||
// origin, sized to preserve the game's own aspect ratio at a comfortable
|
// screen - 2m in front of the local space origin - now just the initial
|
||||||
// visual angle. No per-eye view/projection work is needed for a quad layer
|
// value of g_screen_distance_m (below) rather than a fixed constant.
|
||||||
// at all - the compositor handles reprojecting it per eye itself, which is
|
// Width/curve/distance are all configurable (see g_screen_width_m/
|
||||||
// exactly why this milestone doesn't need xrLocateViews or any stereo
|
// g_screen_curve_deg/g_screen_distance_m above and CONFIG.md's Video
|
||||||
// rendering (see the plan's step A notes).
|
// Options "Initial Screen"/"Game Screen" settings), sized to preserve the
|
||||||
|
// game's own pixel aspect ratio at whatever physical width is currently
|
||||||
|
// configured. No per-eye view/projection work is needed for a quad/
|
||||||
|
// cylinder layer at all - the compositor handles reprojecting it per eye
|
||||||
|
// itself, which is exactly why this milestone doesn't need xrLocateViews
|
||||||
|
// or any stereo rendering (see the plan's step A notes).
|
||||||
#define QUAD_DISTANCE_METERS 2.0f
|
#define QUAD_DISTANCE_METERS 2.0f
|
||||||
#define QUAD_WIDTH_METERS 1.6f
|
|
||||||
|
|
||||||
// Up to this many swapchain images/FBOs - real runtimes report small counts
|
// The menu launcher (just a "Keyboard" button - see MenuOverlay.java) is
|
||||||
// (2-4); this is just a fixed upper bound for the cache arrays below.
|
// small and sits above where the keyboard defaults to; the keyboard keeps
|
||||||
#define MAX_SWAPCHAIN_IMAGES 8
|
// its previous size/position. They're independent XrOverlay instances (see
|
||||||
|
// xr_overlay.h) so both can be shown/hidden/dragged separately - deliberately
|
||||||
|
// positioned apart by default so they don't start out overlapping.
|
||||||
|
#define MENU_JAVA_CLASS "de.ladkau.questshock.MenuOverlay"
|
||||||
|
// 768x800 (was 512x256, then 768x512, then 768x552) - grown to fit the
|
||||||
|
// header/tabs/Video Options content added on top of the original single
|
||||||
|
// "Keyboard" button, then again to fit the stats ribbon below the tab
|
||||||
|
// bar, then again to fit the Video Options tab's screen-size grid; taller
|
||||||
|
// in world space too since MENU_WIDTH_METERS (the physical size) stays
|
||||||
|
// fixed and height_m is derived from it by aspect ratio (see
|
||||||
|
// xr_overlay.c's xr_overlay_init()). Must match MenuOverlay.java's own
|
||||||
|
// WIDTH/HEIGHT, and xr_input.c's MENU_TITLE_BAR_V_FRACTION denominator.
|
||||||
|
#define MENU_WIDTH 768
|
||||||
|
#define MENU_HEIGHT 800
|
||||||
|
#define MENU_DISTANCE_METERS 1.3f
|
||||||
|
// Physical size - was 0.35m, too small to read comfortably at
|
||||||
|
// MENU_DISTANCE_METERS given how much text the header/tabs/stats ribbon
|
||||||
|
// pack in; same pixel resolution (MENU_WIDTH/HEIGHT above) just displayed
|
||||||
|
// bigger, not a layout change.
|
||||||
|
#define MENU_WIDTH_METERS 0.5f
|
||||||
|
#define MENU_DEFAULT_CENTER_X_METERS 0.0f
|
||||||
|
#define MENU_DEFAULT_CENTER_Y_METERS 0.15f
|
||||||
|
|
||||||
|
#define KEYBOARD_JAVA_CLASS "de.ladkau.questshock.KeyboardOverlay"
|
||||||
|
#define KEYBOARD_WIDTH 1024
|
||||||
|
#define KEYBOARD_HEIGHT 768
|
||||||
|
#define KEYBOARD_DISTANCE_METERS 1.5f
|
||||||
|
#define KEYBOARD_WIDTH_METERS 0.8f
|
||||||
|
#define KEYBOARD_DEFAULT_CENTER_X_METERS 0.0f
|
||||||
|
#define KEYBOARD_DEFAULT_CENTER_Y_METERS -0.45f
|
||||||
|
|
||||||
static XrInstance g_instance = XR_NULL_HANDLE;
|
static XrInstance g_instance = XR_NULL_HANDLE;
|
||||||
static XrSystemId g_system_id = XR_NULL_SYSTEM_ID;
|
static XrSystemId g_system_id = XR_NULL_SYSTEM_ID;
|
||||||
static XrSession g_session = XR_NULL_HANDLE;
|
static XrSession g_session = XR_NULL_HANDLE;
|
||||||
static XrSpace g_local_space = XR_NULL_HANDLE;
|
static XrSpace g_local_space = XR_NULL_HANDLE;
|
||||||
|
|
||||||
|
// Everything this app submits/locates (game/menu/keyboard/beam quads, hand
|
||||||
|
// and head poses) is expressed relative to g_play_space, not g_local_space
|
||||||
|
// directly - it's g_local_space translated/rotated by the inverse of the
|
||||||
|
// player's own virtual transform (see xr_session_set_player_transform()'s
|
||||||
|
// own comment in xr_session.h and xr_session_rebuild_play_space() below),
|
||||||
|
// so moving/turning the player moves/turns everything else relative to
|
||||||
|
// them without touching any actual pose-building or hit-testing math
|
||||||
|
// elsewhere in the app - it's all still "relative to the base space",
|
||||||
|
// which just happens to no longer be g_local_space itself.
|
||||||
|
static XrSpace g_play_space = XR_NULL_HANDLE;
|
||||||
|
static float g_player_x = 0.0f;
|
||||||
|
static float g_player_z = 0.0f;
|
||||||
|
static float g_player_yaw_deg = 0.0f;
|
||||||
|
// Set by xr_session_set_player_transform() (including before xr_init() has
|
||||||
|
// even run - see its own comment), cleared once xr_session_rebuild_play_space()
|
||||||
|
// actually rebuilds g_play_space from the current x/z/yaw_deg - checked
|
||||||
|
// once per frame in xr_frame_end() so a stick held at rest costs nothing.
|
||||||
|
static bool g_player_transform_dirty = false;
|
||||||
|
|
||||||
static XrSessionState g_session_state = XR_SESSION_STATE_UNKNOWN;
|
static XrSessionState g_session_state = XR_SESSION_STATE_UNKNOWN;
|
||||||
// Distinct from g_session_state - true from a successful xrBeginSession
|
// Distinct from g_session_state - true from a successful xrBeginSession
|
||||||
// until xrEndSession/loss/exit. Many runtimes only advance the *state*
|
// until xrEndSession/loss/exit. Many runtimes only advance the *state*
|
||||||
@@ -54,55 +109,47 @@ static XrSessionState g_session_state = XR_SESSION_STATE_UNKNOWN;
|
|||||||
// later state.
|
// later state.
|
||||||
static bool g_session_running = false;
|
static bool g_session_running = false;
|
||||||
|
|
||||||
// A single shared swapchain, wide enough to hold the game's own content
|
static XrSwapchainState g_game_swapchain;
|
||||||
// (left) and the menu's content (right) side by side - see xr_frame_end()
|
static XrOverlay *g_menu_overlay = NULL;
|
||||||
// for how each gets its own imageRect/viewport/scissor sub-rectangle within
|
static XrOverlay *g_keyboard_overlay = NULL;
|
||||||
// it. Deliberately not two independent swapchains: submitting two
|
|
||||||
// separately-created swapchains as two XrCompositionLayerQuads makes the
|
|
||||||
// Horizon OS compositor show the game's own content on the menu quad
|
|
||||||
// instead of the menu's - a compositor-level limitation with that
|
|
||||||
// configuration, not something fixable from app-side GL/OpenXR code.
|
|
||||||
// Sharing one swapchain avoids it entirely.
|
|
||||||
static XrSwapchain g_swapchain = XR_NULL_HANDLE;
|
|
||||||
static int g_game_width = 0;
|
|
||||||
static int g_game_height = 0;
|
|
||||||
static int g_menu_width = 0;
|
|
||||||
static int g_menu_height = 0;
|
|
||||||
static GLuint g_swapchain_fbos[MAX_SWAPCHAIN_IMAGES];
|
|
||||||
static uint32_t g_swapchain_image_count = 0;
|
|
||||||
|
|
||||||
static XrTime g_predicted_display_time = 0;
|
static XrTime g_predicted_display_time = 0;
|
||||||
static bool g_frame_should_render = false;
|
static bool g_frame_should_render = false;
|
||||||
static bool g_have_acquired_image = false;
|
static bool g_have_acquired_game_image = false;
|
||||||
|
|
||||||
// This file's GL calls otherwise resolve to gl4es (the only GL symbol
|
// XR_META_performance_metrics support (see xr_session_init_performance_metrics()
|
||||||
// provider linked into this binary - see android/engine-patches/
|
// below) - only present if the runtime advertised it at xrCreateInstance
|
||||||
// 02-android-opengl-es.patch), which is fine for anything shared with the
|
// time (checked via xr_instance_extension_supported()), since unlike the
|
||||||
// engine's own gl4es-routed rendering. But the swapchain images OpenXR
|
// two always-required KHR extensions above, this one is a device-capability
|
||||||
// hands us are real driver texture objects gl4es never created itself,
|
// extra that a build shouldn't hard-depend on.
|
||||||
// and gl4es's own glFramebufferTexture2D can't attach a texture it has no
|
static bool g_performance_metrics_supported = false;
|
||||||
// tracked metadata for. So the FBO *container* is created via gl4es's own
|
static PFN_xrSetPerformanceMetricsStateMETA g_xrSetPerformanceMetricsStateMETA = NULL;
|
||||||
// glGenFramebuffers/glBindFramebuffer (so gl4es recognizes the id as its
|
static PFN_xrQueryPerformanceMetricsCounterMETA g_xrQueryPerformanceMetricsCounterMETA = NULL;
|
||||||
// own and its own per-frame glBindFramebuffer succeeds), while the
|
// Resolved once, at startup, by string-matching the runtime's own
|
||||||
// texture-attach step - the specifically foreign part - goes through the
|
// human-readable counter path names (see xr_session_init_performance_metrics())
|
||||||
// real driver directly, via dlsym against libGLESv2.so.
|
// rather than hardcoding the exact path strings the spec defines - a
|
||||||
typedef void (*PFNQSFRAMEBUFFERTEXTURE2D)(GLenum, GLenum, GLenum, GLuint, GLint);
|
// substring match is robust to spec/runtime wording we can't verify without
|
||||||
typedef GLenum (*PFNQSCHECKFRAMEBUFFERSTATUS)(GLenum);
|
// on-device testing. XR_NULL_PATH (0) if no match was found.
|
||||||
|
static XrPath g_cpu_utilization_path = XR_NULL_PATH;
|
||||||
|
static XrPath g_gpu_utilization_path = XR_NULL_PATH;
|
||||||
|
|
||||||
static PFNQSFRAMEBUFFERTEXTURE2D real_glFramebufferTexture2D;
|
// XR_KHR_composition_layer_cylinder support - same "only if the runtime
|
||||||
static PFNQSCHECKFRAMEBUFFERSTATUS real_glCheckFramebufferStatus;
|
// advertised it" reasoning as g_performance_metrics_supported above. If
|
||||||
|
// unsupported, the curve is forced to 0 (flat) everywhere regardless of
|
||||||
|
// configuration (see xr_get_game_quad_extent()) so the game screen just
|
||||||
|
// silently stays flat instead of failing to submit a layer at all.
|
||||||
|
static bool g_cylinder_layer_supported = false;
|
||||||
|
|
||||||
static bool xr_load_real_gles(void) {
|
// The game screen's currently-configured shape (see qs_config.c's
|
||||||
void *lib = dlopen("libGLESv2.so", RTLD_NOW | RTLD_LOCAL);
|
// qs_screen_profile_notify_loop_mode(), which is what actually calls
|
||||||
if (lib == NULL) {
|
// xr_session_set_game_screen_shape() below - this file has no notion of
|
||||||
LOGE("XR: dlopen(libGLESv2.so) failed: %s", dlerror());
|
// "which profile" is active, only "what shape is current"). Defaults
|
||||||
return false;
|
// match the game quad's own original, pre-this-feature hardcoded width
|
||||||
}
|
// (1.6m) and lack of curve, so a build that never touches the new config
|
||||||
real_glFramebufferTexture2D = (PFNQSFRAMEBUFFERTEXTURE2D)dlsym(lib, "glFramebufferTexture2D");
|
// options renders identically to before.
|
||||||
real_glCheckFramebufferStatus =
|
static float g_screen_width_m = 1.6f;
|
||||||
(PFNQSCHECKFRAMEBUFFERSTATUS)dlsym(lib, "glCheckFramebufferStatus");
|
static float g_screen_curve_deg = 0.0f;
|
||||||
return real_glFramebufferTexture2D && real_glCheckFramebufferStatus;
|
static float g_screen_distance_m = QUAD_DISTANCE_METERS;
|
||||||
}
|
|
||||||
|
|
||||||
static bool xr_check(XrResult result, const char *what) {
|
static bool xr_check(XrResult result, const char *what) {
|
||||||
if (XR_SUCCEEDED(result))
|
if (XR_SUCCEEDED(result))
|
||||||
@@ -124,6 +171,106 @@ static PFN_xrVoidFunction xr_get_proc(const char *name) {
|
|||||||
return fn;
|
return fn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Queried before xrCreateInstance (which is why this takes no instance
|
||||||
|
// handle - xrEnumerateInstanceExtensionProperties is one of the few
|
||||||
|
// functions that works without one) to decide whether it's safe to add
|
||||||
|
// `name` to enabledExtensionNames - passing an unsupported extension there
|
||||||
|
// would fail instance creation outright, unlike a missing optional feature
|
||||||
|
// discovered afterward.
|
||||||
|
static bool xr_instance_extension_supported(const char *name) {
|
||||||
|
uint32_t count = 0;
|
||||||
|
if (!xr_check(xrEnumerateInstanceExtensionProperties(NULL, 0, &count, NULL),
|
||||||
|
"xrEnumerateInstanceExtensionProperties"))
|
||||||
|
return false;
|
||||||
|
XrExtensionProperties *props = (XrExtensionProperties *)malloc(sizeof(XrExtensionProperties) * count);
|
||||||
|
for (uint32_t i = 0; i < count; i++)
|
||||||
|
props[i].type = XR_TYPE_EXTENSION_PROPERTIES;
|
||||||
|
bool supported = false;
|
||||||
|
if (xr_check(xrEnumerateInstanceExtensionProperties(NULL, count, &count, props),
|
||||||
|
"xrEnumerateInstanceExtensionProperties")) {
|
||||||
|
for (uint32_t i = 0; i < count; i++) {
|
||||||
|
if (strcmp(props[i].extensionName, name) == 0) {
|
||||||
|
supported = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
free(props);
|
||||||
|
return supported;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case-insensitively checks whether `needle` occurs anywhere in `haystack` -
|
||||||
|
// used to identify a counter path (e.g. ".../device/cpu_utilization_average")
|
||||||
|
// by a short, stable substring instead of matching its exact full spelling.
|
||||||
|
static bool xr_path_name_contains(const char *haystack, const char *needle) {
|
||||||
|
size_t hlen = strlen(haystack);
|
||||||
|
char lower[256];
|
||||||
|
if (hlen >= sizeof(lower))
|
||||||
|
hlen = sizeof(lower) - 1;
|
||||||
|
for (size_t i = 0; i < hlen; i++)
|
||||||
|
lower[i] = (char)tolower((unsigned char)haystack[i]);
|
||||||
|
lower[hlen] = '\0';
|
||||||
|
return strstr(lower, needle) != NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enables XR_META_performance_metrics (if the runtime advertised it - see
|
||||||
|
// xr_instance_extension_supported() above) and resolves the CPU/GPU
|
||||||
|
// device-utilization counter paths xr_session_update_frame_stats() polls
|
||||||
|
// every frame. Called once, right after xrCreateSession succeeds. Leaves
|
||||||
|
// g_cpu_utilization_path/g_gpu_utilization_path at XR_NULL_PATH (their
|
||||||
|
// getters then report -1.0f) if anything here doesn't pan out - this is a
|
||||||
|
// nice-to-have stats display, not something worth failing session bring-up
|
||||||
|
// over.
|
||||||
|
static void xr_session_init_performance_metrics(void) {
|
||||||
|
if (!g_performance_metrics_supported)
|
||||||
|
return;
|
||||||
|
|
||||||
|
PFN_xrEnumeratePerformanceMetricsCounterPathsMETA enumeratePaths =
|
||||||
|
(PFN_xrEnumeratePerformanceMetricsCounterPathsMETA)xr_get_proc(
|
||||||
|
"xrEnumeratePerformanceMetricsCounterPathsMETA");
|
||||||
|
g_xrSetPerformanceMetricsStateMETA = (PFN_xrSetPerformanceMetricsStateMETA)xr_get_proc(
|
||||||
|
"xrSetPerformanceMetricsStateMETA");
|
||||||
|
g_xrQueryPerformanceMetricsCounterMETA = (PFN_xrQueryPerformanceMetricsCounterMETA)xr_get_proc(
|
||||||
|
"xrQueryPerformanceMetricsCounterMETA");
|
||||||
|
if (enumeratePaths == NULL || g_xrSetPerformanceMetricsStateMETA == NULL ||
|
||||||
|
g_xrQueryPerformanceMetricsCounterMETA == NULL)
|
||||||
|
return;
|
||||||
|
|
||||||
|
XrPerformanceMetricsStateMETA state = {XR_TYPE_PERFORMANCE_METRICS_STATE_META};
|
||||||
|
state.enabled = XR_TRUE;
|
||||||
|
if (!xr_check(g_xrSetPerformanceMetricsStateMETA(g_session, &state),
|
||||||
|
"xrSetPerformanceMetricsStateMETA"))
|
||||||
|
return;
|
||||||
|
|
||||||
|
uint32_t pathCount = 0;
|
||||||
|
if (!xr_check(enumeratePaths(g_instance, 0, &pathCount, NULL),
|
||||||
|
"xrEnumeratePerformanceMetricsCounterPathsMETA"))
|
||||||
|
return;
|
||||||
|
XrPath *paths = (XrPath *)malloc(sizeof(XrPath) * pathCount);
|
||||||
|
if (!xr_check(enumeratePaths(g_instance, pathCount, &pathCount, paths),
|
||||||
|
"xrEnumeratePerformanceMetricsCounterPathsMETA")) {
|
||||||
|
free(paths);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (uint32_t i = 0; i < pathCount; i++) {
|
||||||
|
char pathName[256];
|
||||||
|
uint32_t pathNameLen = 0;
|
||||||
|
if (!xr_check(xrPathToString(g_instance, paths[i], sizeof(pathName), &pathNameLen, pathName),
|
||||||
|
"xrPathToString"))
|
||||||
|
continue;
|
||||||
|
if (g_cpu_utilization_path == XR_NULL_PATH && xr_path_name_contains(pathName, "cpu_utilization"))
|
||||||
|
g_cpu_utilization_path = paths[i];
|
||||||
|
if (g_gpu_utilization_path == XR_NULL_PATH && xr_path_name_contains(pathName, "gpu_utilization"))
|
||||||
|
g_gpu_utilization_path = paths[i];
|
||||||
|
}
|
||||||
|
free(paths);
|
||||||
|
|
||||||
|
LOGI("XR: performance metrics cpu_utilization=%s gpu_utilization=%s",
|
||||||
|
g_cpu_utilization_path != XR_NULL_PATH ? "found" : "not found",
|
||||||
|
g_gpu_utilization_path != XR_NULL_PATH ? "found" : "not found");
|
||||||
|
}
|
||||||
|
|
||||||
// The Android OpenXR loader needs the JavaVM/context before anything else -
|
// The Android OpenXR loader needs the JavaVM/context before anything else -
|
||||||
// separate from XR_KHR_android_create_instance below, which only affects
|
// separate from XR_KHR_android_create_instance below, which only affects
|
||||||
// xrCreateInstance itself. Skipping this is a common cause of
|
// xrCreateInstance itself. Skipping this is a common cause of
|
||||||
@@ -158,6 +305,65 @@ static EGLConfig xr_get_current_egl_config(EGLDisplay display, EGLContext contex
|
|||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#define QS_DEG_TO_RAD (3.14159265358979323846f / 180.0f)
|
||||||
|
|
||||||
|
// Destroys/recreates g_play_space from g_local_space, offset by the
|
||||||
|
// inverse of the player's current virtual transform (g_player_x/z/yaw_deg) -
|
||||||
|
// see xr_session_set_player_transform()'s comment in xr_session.h for why
|
||||||
|
// this is the inverse rather than the transform itself. Given
|
||||||
|
// V = Translate(x, 0, z) * Rotate_Y(yaw_deg) (the player's own transform,
|
||||||
|
// "apparent pose in play space = V composed with real pose in local
|
||||||
|
// space"), the new space's poseInReferenceSpace must be V's inverse:
|
||||||
|
// orientation = Rotate_Y(-yaw_deg), position = Rotate_Y(-yaw_deg) * (-x, 0, -z).
|
||||||
|
// Called once, right after g_local_space itself is created (picking up
|
||||||
|
// whatever qs_config_load_startup() already seeded before xr_init() ever
|
||||||
|
// ran), and once per frame from xr_frame_end() whenever
|
||||||
|
// g_player_transform_dirty says the transform actually changed since the
|
||||||
|
// last rebuild - a plain stick-at-rest frame costs nothing beyond that one
|
||||||
|
// bool check. g_session must already exist.
|
||||||
|
static void xr_session_rebuild_play_space(void) {
|
||||||
|
if (g_play_space != XR_NULL_HANDLE) {
|
||||||
|
xrDestroySpace(g_play_space);
|
||||||
|
g_play_space = XR_NULL_HANDLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
float theta = g_player_yaw_deg * QS_DEG_TO_RAD;
|
||||||
|
float half = -theta * 0.5f;
|
||||||
|
float c = cosf(-theta), s = sinf(-theta);
|
||||||
|
float vx = -g_player_x, vz = -g_player_z;
|
||||||
|
|
||||||
|
XrReferenceSpaceCreateInfo spaceInfo = {XR_TYPE_REFERENCE_SPACE_CREATE_INFO};
|
||||||
|
spaceInfo.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_LOCAL;
|
||||||
|
spaceInfo.poseInReferenceSpace.orientation.x = 0.0f;
|
||||||
|
spaceInfo.poseInReferenceSpace.orientation.y = sinf(half);
|
||||||
|
spaceInfo.poseInReferenceSpace.orientation.z = 0.0f;
|
||||||
|
spaceInfo.poseInReferenceSpace.orientation.w = cosf(half);
|
||||||
|
spaceInfo.poseInReferenceSpace.position.x = vx * c + vz * s;
|
||||||
|
spaceInfo.poseInReferenceSpace.position.y = 0.0f;
|
||||||
|
spaceInfo.poseInReferenceSpace.position.z = -vx * s + vz * c;
|
||||||
|
xr_check(xrCreateReferenceSpace(g_session, &spaceInfo, &g_play_space),
|
||||||
|
"xrCreateReferenceSpace(play)");
|
||||||
|
|
||||||
|
g_player_transform_dirty = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void xr_session_set_player_transform(float x, float z, float yaw_deg) {
|
||||||
|
g_player_x = x;
|
||||||
|
g_player_z = z;
|
||||||
|
g_player_yaw_deg = yaw_deg;
|
||||||
|
g_player_transform_dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void xr_session_get_player_transform(float *x, float *z, float *yaw_deg) {
|
||||||
|
*x = g_player_x;
|
||||||
|
*z = g_player_z;
|
||||||
|
*yaw_deg = g_player_yaw_deg;
|
||||||
|
}
|
||||||
|
|
||||||
|
XrSpace xr_session_get_local_space(void) { return g_local_space; }
|
||||||
|
|
||||||
|
XrTime xr_session_get_predicted_display_time(void) { return g_predicted_display_time; }
|
||||||
|
|
||||||
static bool xr_create_instance_and_session(int game_width, int game_height) {
|
static bool xr_create_instance_and_session(int game_width, int game_height) {
|
||||||
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
|
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
|
||||||
jobject activity = (jobject)SDL_AndroidGetActivity();
|
jobject activity = (jobject)SDL_AndroidGetActivity();
|
||||||
@@ -171,10 +377,26 @@ static bool xr_create_instance_and_session(int game_width, int game_height) {
|
|||||||
if (!xr_initialize_android_loader(vm, activity))
|
if (!xr_initialize_android_loader(vm, activity))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
const char *extensions[] = {
|
g_performance_metrics_supported =
|
||||||
|
xr_instance_extension_supported(XR_META_PERFORMANCE_METRICS_EXTENSION_NAME);
|
||||||
|
g_cylinder_layer_supported =
|
||||||
|
xr_instance_extension_supported(XR_KHR_COMPOSITION_LAYER_CYLINDER_EXTENSION_NAME);
|
||||||
|
|
||||||
|
// The two KHR extensions here are always required; the other two are
|
||||||
|
// each independently optional device capabilities (see
|
||||||
|
// g_performance_metrics_supported/g_cylinder_layer_supported's own
|
||||||
|
// comments) - appended only if actually advertised, since listing an
|
||||||
|
// unsupported extension fails xrCreateInstance entirely, not just that
|
||||||
|
// one feature.
|
||||||
|
const char *extensions[4] = {
|
||||||
XR_KHR_ANDROID_CREATE_INSTANCE_EXTENSION_NAME,
|
XR_KHR_ANDROID_CREATE_INSTANCE_EXTENSION_NAME,
|
||||||
XR_KHR_OPENGL_ES_ENABLE_EXTENSION_NAME,
|
XR_KHR_OPENGL_ES_ENABLE_EXTENSION_NAME,
|
||||||
};
|
};
|
||||||
|
uint32_t extensionCount = 2;
|
||||||
|
if (g_performance_metrics_supported)
|
||||||
|
extensions[extensionCount++] = XR_META_PERFORMANCE_METRICS_EXTENSION_NAME;
|
||||||
|
if (g_cylinder_layer_supported)
|
||||||
|
extensions[extensionCount++] = XR_KHR_COMPOSITION_LAYER_CYLINDER_EXTENSION_NAME;
|
||||||
|
|
||||||
XrInstanceCreateInfoAndroidKHR androidInfo = {XR_TYPE_INSTANCE_CREATE_INFO_ANDROID_KHR};
|
XrInstanceCreateInfoAndroidKHR androidInfo = {XR_TYPE_INSTANCE_CREATE_INFO_ANDROID_KHR};
|
||||||
androidInfo.applicationVM = vm;
|
androidInfo.applicationVM = vm;
|
||||||
@@ -182,7 +404,7 @@ static bool xr_create_instance_and_session(int game_width, int game_height) {
|
|||||||
|
|
||||||
XrInstanceCreateInfo createInfo = {XR_TYPE_INSTANCE_CREATE_INFO};
|
XrInstanceCreateInfo createInfo = {XR_TYPE_INSTANCE_CREATE_INFO};
|
||||||
createInfo.next = &androidInfo;
|
createInfo.next = &androidInfo;
|
||||||
createInfo.enabledExtensionCount = sizeof(extensions) / sizeof(extensions[0]);
|
createInfo.enabledExtensionCount = extensionCount;
|
||||||
createInfo.enabledExtensionNames = extensions;
|
createInfo.enabledExtensionNames = extensions;
|
||||||
strncpy(createInfo.applicationInfo.applicationName, "QuestShock",
|
strncpy(createInfo.applicationInfo.applicationName, "QuestShock",
|
||||||
XR_MAX_APPLICATION_NAME_SIZE - 1);
|
XR_MAX_APPLICATION_NAME_SIZE - 1);
|
||||||
@@ -202,10 +424,12 @@ static bool xr_create_instance_and_session(int game_width, int game_height) {
|
|||||||
// Required by spec before creating an OpenGL ES-backed session, even
|
// Required by spec before creating an OpenGL ES-backed session, even
|
||||||
// though we don't gate on the returned min/max API version here.
|
// though we don't gate on the returned min/max API version here.
|
||||||
PFN_xrGetOpenGLESGraphicsRequirementsKHR getGLESRequirements =
|
PFN_xrGetOpenGLESGraphicsRequirementsKHR getGLESRequirements =
|
||||||
(PFN_xrGetOpenGLESGraphicsRequirementsKHR)xr_get_proc("xrGetOpenGLESGraphicsRequirementsKHR");
|
(PFN_xrGetOpenGLESGraphicsRequirementsKHR)xr_get_proc(
|
||||||
|
"xrGetOpenGLESGraphicsRequirementsKHR");
|
||||||
if (getGLESRequirements == NULL)
|
if (getGLESRequirements == NULL)
|
||||||
return false;
|
return false;
|
||||||
XrGraphicsRequirementsOpenGLESKHR glesRequirements = {XR_TYPE_GRAPHICS_REQUIREMENTS_OPENGL_ES_KHR};
|
XrGraphicsRequirementsOpenGLESKHR glesRequirements = {
|
||||||
|
XR_TYPE_GRAPHICS_REQUIREMENTS_OPENGL_ES_KHR};
|
||||||
if (!xr_check(getGLESRequirements(g_instance, g_system_id, &glesRequirements),
|
if (!xr_check(getGLESRequirements(g_instance, g_system_id, &glesRequirements),
|
||||||
"xrGetOpenGLESGraphicsRequirementsKHR"))
|
"xrGetOpenGLESGraphicsRequirementsKHR"))
|
||||||
return false;
|
return false;
|
||||||
@@ -229,6 +453,8 @@ static bool xr_create_instance_and_session(int game_width, int game_height) {
|
|||||||
if (!xr_check(xrCreateSession(g_instance, &sessionInfo, &g_session), "xrCreateSession"))
|
if (!xr_check(xrCreateSession(g_instance, &sessionInfo, &g_session), "xrCreateSession"))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
|
xr_session_init_performance_metrics();
|
||||||
|
|
||||||
XrReferenceSpaceCreateInfo spaceInfo = {XR_TYPE_REFERENCE_SPACE_CREATE_INFO};
|
XrReferenceSpaceCreateInfo spaceInfo = {XR_TYPE_REFERENCE_SPACE_CREATE_INFO};
|
||||||
spaceInfo.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_LOCAL;
|
spaceInfo.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_LOCAL;
|
||||||
spaceInfo.poseInReferenceSpace.orientation.w = 1.0f;
|
spaceInfo.poseInReferenceSpace.orientation.w = 1.0f;
|
||||||
@@ -236,9 +462,13 @@ static bool xr_create_instance_and_session(int game_width, int game_height) {
|
|||||||
"xrCreateReferenceSpace"))
|
"xrCreateReferenceSpace"))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
// Non-fatal if it fails (e.g. no controllers bound yet) - rendering
|
// Picks up whatever qs_config_load_startup() already stored into
|
||||||
// keeps working either way, just without the laser pointer.
|
// g_player_x/z/yaw_deg via xr_session_set_player_transform() before
|
||||||
xr_input_init(g_instance, g_session);
|
// this function ever ran (that call is pure state, no XR API calls, so
|
||||||
|
// it works fine even this early) - so a saved position is already in
|
||||||
|
// effect from the very first frame, not just after the player first
|
||||||
|
// moves a thumbstick.
|
||||||
|
xr_session_rebuild_play_space();
|
||||||
|
|
||||||
// Prefer a plain linear 8-bit format over GL_SRGB8_ALPHA8: the source
|
// Prefer a plain linear 8-bit format over GL_SRGB8_ALPHA8: the source
|
||||||
// SDL surface pixels are already sRGB-encoded (as ordinary 8-bit image
|
// SDL surface pixels are already sRGB-encoded (as ordinary 8-bit image
|
||||||
@@ -246,7 +476,10 @@ static bool xr_create_instance_and_session(int game_width, int game_height) {
|
|||||||
// GL_RGBA with no decode step anywhere in this path, matching the
|
// GL_RGBA with no decode step anywhere in this path, matching the
|
||||||
// desktop SDL_RenderCopy path this replaces - an sRGB swapchain format
|
// desktop SDL_RenderCopy path this replaces - an sRGB swapchain format
|
||||||
// would auto-gamma-encode on write and double-encode already-encoded
|
// would auto-gamma-encode on write and double-encode already-encoded
|
||||||
// data.
|
// data. Every swapchain below (game quad, menu/keyboard overlays, and
|
||||||
|
// - via xr_input_init() - the per-hand laser-beam overlays) shares
|
||||||
|
// this one choice - the compositor's supported format set doesn't
|
||||||
|
// depend on swapchain size.
|
||||||
uint32_t formatCount = 0;
|
uint32_t formatCount = 0;
|
||||||
xrEnumerateSwapchainFormats(g_session, 0, &formatCount, NULL);
|
xrEnumerateSwapchainFormats(g_session, 0, &formatCount, NULL);
|
||||||
int64_t *formats = (int64_t *)malloc(sizeof(int64_t) * formatCount);
|
int64_t *formats = (int64_t *)malloc(sizeof(int64_t) * formatCount);
|
||||||
@@ -261,82 +494,42 @@ static bool xr_create_instance_and_session(int game_width, int game_height) {
|
|||||||
}
|
}
|
||||||
free(formats);
|
free(formats);
|
||||||
|
|
||||||
g_game_width = game_width;
|
// Non-fatal if it fails (e.g. no controllers bound yet) - rendering
|
||||||
g_game_height = game_height;
|
// keeps working either way, just without the laser pointer/beams.
|
||||||
xr_menu_get_content_size(&g_menu_width, &g_menu_height);
|
xr_input_init(g_instance, g_session, chosenFormat);
|
||||||
// The game's own content occupies (0,0)-(game_width,game_height); the
|
|
||||||
// menu's occupies (game_width,0)-(game_width+menu_width,menu_height) -
|
|
||||||
// side by side in one image, wide enough and tall enough for both.
|
|
||||||
uint32_t sharedWidth = (uint32_t)(g_game_width + g_menu_width);
|
|
||||||
uint32_t sharedHeight = (uint32_t)(g_game_height > g_menu_height ? g_game_height : g_menu_height);
|
|
||||||
|
|
||||||
XrSwapchainCreateInfo swapchainInfo = {XR_TYPE_SWAPCHAIN_CREATE_INFO};
|
if (!xr_swapchain_create(g_instance, g_session, chosenFormat, game_width, game_height,
|
||||||
swapchainInfo.usageFlags = XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT | XR_SWAPCHAIN_USAGE_SAMPLED_BIT;
|
&g_game_swapchain)) {
|
||||||
swapchainInfo.format = chosenFormat;
|
LOGE("XR: game swapchain setup failed");
|
||||||
swapchainInfo.sampleCount = 1;
|
|
||||||
swapchainInfo.width = sharedWidth;
|
|
||||||
swapchainInfo.height = sharedHeight;
|
|
||||||
swapchainInfo.faceCount = 1;
|
|
||||||
swapchainInfo.arraySize = 1;
|
|
||||||
swapchainInfo.mipCount = 1;
|
|
||||||
if (!xr_check(xrCreateSwapchain(g_session, &swapchainInfo, &g_swapchain),
|
|
||||||
"xrCreateSwapchain"))
|
|
||||||
return false;
|
|
||||||
|
|
||||||
uint32_t imageCount = 0;
|
|
||||||
xrEnumerateSwapchainImages(g_swapchain, 0, &imageCount, NULL);
|
|
||||||
if (imageCount > MAX_SWAPCHAIN_IMAGES) {
|
|
||||||
LOGE("XR: swapchain reports %u images, only room for %d", imageCount,
|
|
||||||
MAX_SWAPCHAIN_IMAGES);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Zero-initialized, not just `.type` set per element - these structs
|
|
||||||
// also carry a `next` field the runtime may read, and an
|
|
||||||
// uninitialized stack array would leave it as garbage.
|
|
||||||
XrSwapchainImageOpenGLESKHR images[MAX_SWAPCHAIN_IMAGES] = {0};
|
|
||||||
for (uint32_t i = 0; i < imageCount; i++)
|
|
||||||
images[i].type = XR_TYPE_SWAPCHAIN_IMAGE_OPENGL_ES_KHR;
|
|
||||||
if (!xr_check(xrEnumerateSwapchainImages(g_swapchain, imageCount, &imageCount,
|
|
||||||
(XrSwapchainImageBaseHeader *)images),
|
|
||||||
"xrEnumerateSwapchainImages"))
|
|
||||||
return false;
|
|
||||||
g_swapchain_image_count = imageCount;
|
|
||||||
|
|
||||||
if (!xr_load_real_gles()) {
|
|
||||||
LOGE("XR: couldn't resolve real GLES FBO functions via dlsym");
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wraps each swapchain-provided texture in its own framebuffer, matching
|
// Both non-fatal - the game keeps rendering with no such panel if
|
||||||
// how OpenGL.cc's own CreateFrameBuffer() wraps backupBuffer - just
|
// either fails (e.g. the Java class couldn't be resolved via JNI).
|
||||||
// without a depth/stencil attachment, since the final composite draw
|
XrOverlayConfig menuConfig = {
|
||||||
// (see opengl_swap_and_restore) never needs one. The Gen/Bind calls are
|
.java_class_name = MENU_JAVA_CLASS,
|
||||||
// gl4es's own (linked, not dlsym'd) - see the comment above
|
.width = MENU_WIDTH,
|
||||||
// real_glFramebufferTexture2D for why.
|
.height = MENU_HEIGHT,
|
||||||
bool all_complete = true;
|
.distance_m = MENU_DISTANCE_METERS,
|
||||||
for (uint32_t i = 0; i < imageCount; i++) {
|
.width_m = MENU_WIDTH_METERS,
|
||||||
glGenFramebuffers(1, &g_swapchain_fbos[i]);
|
.default_center_x_m = MENU_DEFAULT_CENTER_X_METERS,
|
||||||
glBindFramebuffer(GL_FRAMEBUFFER, g_swapchain_fbos[i]);
|
.default_center_y_m = MENU_DEFAULT_CENTER_Y_METERS,
|
||||||
real_glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D,
|
};
|
||||||
images[i].image, 0);
|
g_menu_overlay = xr_overlay_create(g_instance, g_session, chosenFormat, &menuConfig);
|
||||||
GLenum status = real_glCheckFramebufferStatus(GL_FRAMEBUFFER);
|
|
||||||
if (status != GL_FRAMEBUFFER_COMPLETE) {
|
|
||||||
LOGE("XR: swapchain FBO %u incomplete: 0x%x", i, status);
|
|
||||||
all_complete = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
|
||||||
if (!all_complete)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
LOGI("XR: instance/session/swapchain ready (%ux%u shared, %dx%d game + %dx%d menu, %u images)",
|
XrOverlayConfig keyboardConfig = {
|
||||||
sharedWidth, sharedHeight, g_game_width, g_game_height, g_menu_width, g_menu_height,
|
.java_class_name = KEYBOARD_JAVA_CLASS,
|
||||||
imageCount);
|
.width = KEYBOARD_WIDTH,
|
||||||
|
.height = KEYBOARD_HEIGHT,
|
||||||
// Also non-fatal - the game keeps rendering with no menu quad if this
|
.distance_m = KEYBOARD_DISTANCE_METERS,
|
||||||
// fails (e.g. MenuOverlay couldn't be resolved via JNI).
|
.width_m = KEYBOARD_WIDTH_METERS,
|
||||||
xr_menu_init(g_instance, g_session);
|
.default_center_x_m = KEYBOARD_DEFAULT_CENTER_X_METERS,
|
||||||
|
.default_center_y_m = KEYBOARD_DEFAULT_CENTER_Y_METERS,
|
||||||
|
};
|
||||||
|
g_keyboard_overlay = xr_overlay_create(g_instance, g_session, chosenFormat, &keyboardConfig);
|
||||||
|
|
||||||
|
LOGI("XR: instance/session ready (game %dx%d, menu %dx%d, keyboard %dx%d)", game_width,
|
||||||
|
game_height, MENU_WIDTH, MENU_HEIGHT, KEYBOARD_WIDTH, KEYBOARD_HEIGHT);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,6 +542,9 @@ bool xr_init(int game_width, int game_height) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
XrOverlay *xr_session_get_menu_overlay(void) { return g_menu_overlay; }
|
||||||
|
XrOverlay *xr_session_get_keyboard_overlay(void) { return g_keyboard_overlay; }
|
||||||
|
|
||||||
void xr_poll_events(void) {
|
void xr_poll_events(void) {
|
||||||
if (g_instance == XR_NULL_HANDLE)
|
if (g_instance == XR_NULL_HANDLE)
|
||||||
return;
|
return;
|
||||||
@@ -369,7 +565,8 @@ void xr_poll_events(void) {
|
|||||||
if (g_session_state == XR_SESSION_STATE_READY) {
|
if (g_session_state == XR_SESSION_STATE_READY) {
|
||||||
XrSessionBeginInfo beginInfo = {XR_TYPE_SESSION_BEGIN_INFO};
|
XrSessionBeginInfo beginInfo = {XR_TYPE_SESSION_BEGIN_INFO};
|
||||||
beginInfo.primaryViewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO;
|
beginInfo.primaryViewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO;
|
||||||
g_session_running = xr_check(xrBeginSession(g_session, &beginInfo), "xrBeginSession");
|
g_session_running =
|
||||||
|
xr_check(xrBeginSession(g_session, &beginInfo), "xrBeginSession");
|
||||||
} else if (g_session_state == XR_SESSION_STATE_STOPPING) {
|
} else if (g_session_state == XR_SESSION_STATE_STOPPING) {
|
||||||
xr_check(xrEndSession(g_session), "xrEndSession");
|
xr_check(xrEndSession(g_session), "xrEndSession");
|
||||||
g_session_running = false;
|
g_session_running = false;
|
||||||
@@ -379,12 +576,20 @@ void xr_poll_events(void) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// xrPollEvent() leaves gl4es reporting a stray GL_INVALID_OPERATION on
|
||||||
|
// every call while the session is running - drain it (looped, since
|
||||||
|
// more than one can accumulate across xrPollEvent()'s possibly-multiple
|
||||||
|
// iterations above) so it doesn't surface as a false positive at the
|
||||||
|
// next unrelated glGetError() check in this app's own rendering code.
|
||||||
|
while (glGetError() != GL_NO_ERROR) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool xr_is_session_running(void) { return g_session_running; }
|
bool xr_is_session_running(void) { return g_session_running; }
|
||||||
|
|
||||||
bool xr_frame_begin(void) {
|
bool xr_frame_begin(void) {
|
||||||
g_have_acquired_image = false;
|
g_have_acquired_game_image = false;
|
||||||
if (!xr_is_session_running())
|
if (!xr_is_session_running())
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
@@ -402,139 +607,212 @@ bool xr_frame_begin(void) {
|
|||||||
if (!g_frame_should_render)
|
if (!g_frame_should_render)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
uint32_t imageIndex = 0;
|
g_have_acquired_game_image = xr_swapchain_acquire(g_instance, &g_game_swapchain);
|
||||||
XrSwapchainImageAcquireInfo acquireInfo = {XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO};
|
return g_have_acquired_game_image;
|
||||||
if (!xr_check(xrAcquireSwapchainImage(g_swapchain, &acquireInfo, &imageIndex),
|
|
||||||
"xrAcquireSwapchainImage"))
|
|
||||||
return false;
|
|
||||||
|
|
||||||
XrSwapchainImageWaitInfo waitImageInfo = {XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO};
|
|
||||||
waitImageInfo.timeout = XR_INFINITE_DURATION;
|
|
||||||
if (!xr_check(xrWaitSwapchainImage(g_swapchain, &waitImageInfo), "xrWaitSwapchainImage"))
|
|
||||||
return false;
|
|
||||||
|
|
||||||
// gl4es's own (linked, not dlsym'd) bind - this targets an FBO id
|
|
||||||
// gl4es itself created (see xr_create_instance_and_session()), so its
|
|
||||||
// own "current FBO" bookkeeping updates correctly and its immediate-
|
|
||||||
// mode draw calls in android_draw_surface_as_quad() land in the right
|
|
||||||
// place. Scissor-limited to the game's own sub-rectangle of the shared
|
|
||||||
// image (see g_swapchain's comment) - glViewport alone would already
|
|
||||||
// keep the engine's draw calls within that rectangle, but glClear
|
|
||||||
// ignores viewport and would otherwise wipe the menu's own region of
|
|
||||||
// the same image whenever the engine clears before drawing.
|
|
||||||
glBindFramebuffer(GL_FRAMEBUFFER, g_swapchain_fbos[imageIndex]);
|
|
||||||
glViewport(0, 0, g_game_width, g_game_height);
|
|
||||||
glEnable(GL_SCISSOR_TEST);
|
|
||||||
glScissor(0, 0, g_game_width, g_game_height);
|
|
||||||
|
|
||||||
g_have_acquired_image = true;
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backs xr_session_get_fps()/get_frame_ms() (see xr_session.h) - exponential
|
||||||
|
// smoothing (rather than a plain per-frame instantaneous value) so
|
||||||
|
// MenuOverlay's header display doesn't visibly jitter every single frame.
|
||||||
|
// Called once per xr_frame_end() that reaches its own end (i.e. once per
|
||||||
|
// real XR frame, whether or not the game itself actually rendered anything
|
||||||
|
// new into it this frame - matches the OpenXR compositor's own frame
|
||||||
|
// cadence, which is what "FPS" means to someone looking at this display).
|
||||||
|
static float s_fps = 0.0f;
|
||||||
|
static float s_frame_ms = 0.0f;
|
||||||
|
static float s_cpu_utilization = -1.0f;
|
||||||
|
static float s_gpu_utilization = -1.0f;
|
||||||
|
|
||||||
|
// Reads one XR_META_performance_metrics counter (if it was found at
|
||||||
|
// startup - see xr_session_init_performance_metrics()) and folds it into
|
||||||
|
// *smoothed with the same exponential smoothing as FPS/frame time above.
|
||||||
|
// Leaves *smoothed at -1.0f (its "unsupported" sentinel) untouched if the
|
||||||
|
// path wasn't found, the query fails, or the runtime didn't mark its
|
||||||
|
// floatValue valid this call.
|
||||||
|
static void xr_session_update_utilization(XrPath path, float *smoothed) {
|
||||||
|
if (path == XR_NULL_PATH || g_xrQueryPerformanceMetricsCounterMETA == NULL)
|
||||||
|
return;
|
||||||
|
XrPerformanceMetricsCounterMETA counter = {XR_TYPE_PERFORMANCE_METRICS_COUNTER_META};
|
||||||
|
if (!xr_check(g_xrQueryPerformanceMetricsCounterMETA(g_session, path, &counter),
|
||||||
|
"xrQueryPerformanceMetricsCounterMETA"))
|
||||||
|
return;
|
||||||
|
if (!(counter.counterFlags & XR_PERFORMANCE_METRICS_COUNTER_FLOAT_VALUE_VALID_BIT_META))
|
||||||
|
return;
|
||||||
|
const float kSmoothing = 0.1f;
|
||||||
|
*smoothed =
|
||||||
|
(*smoothed < 0.0f) ? counter.floatValue : *smoothed * (1.0f - kSmoothing) + counter.floatValue * kSmoothing;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void xr_session_update_frame_stats(void) {
|
||||||
|
static Uint32 s_last_ticks = 0;
|
||||||
|
Uint32 now = SDL_GetTicks();
|
||||||
|
if (s_last_ticks != 0) {
|
||||||
|
Uint32 delta_ms = now - s_last_ticks;
|
||||||
|
if (delta_ms > 0) {
|
||||||
|
float instant_fps = 1000.0f / (float)delta_ms;
|
||||||
|
const float kSmoothing = 0.1f;
|
||||||
|
s_fps = (s_fps == 0.0f) ? instant_fps : s_fps * (1.0f - kSmoothing) + instant_fps * kSmoothing;
|
||||||
|
s_frame_ms =
|
||||||
|
(s_frame_ms == 0.0f) ? (float)delta_ms : s_frame_ms * (1.0f - kSmoothing) + (float)delta_ms * kSmoothing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s_last_ticks = now;
|
||||||
|
|
||||||
|
xr_session_update_utilization(g_cpu_utilization_path, &s_cpu_utilization);
|
||||||
|
xr_session_update_utilization(g_gpu_utilization_path, &s_gpu_utilization);
|
||||||
|
}
|
||||||
|
|
||||||
|
float xr_session_get_fps(void) { return s_fps; }
|
||||||
|
|
||||||
|
float xr_session_get_frame_ms(void) { return s_frame_ms; }
|
||||||
|
|
||||||
|
float xr_session_get_cpu_utilization(void) { return s_cpu_utilization; }
|
||||||
|
|
||||||
|
float xr_session_get_gpu_utilization(void) { return s_gpu_utilization; }
|
||||||
|
|
||||||
void xr_frame_end(void) {
|
void xr_frame_end(void) {
|
||||||
if (g_session == XR_NULL_HANDLE)
|
if (g_session == XR_NULL_HANDLE)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Draw the laser-pointer reticle into the still-bound swapchain
|
// Rebuilt before xr_input_sync_and_draw() below so this same frame's
|
||||||
// framebuffer before releasing it, so it composites on top of
|
// hit-testing and layer submission both see whatever the player's
|
||||||
// whatever this frame's game content already drew there. Syncing
|
// thumbstick input (or a config-driven "Reset Position"/Load
|
||||||
// actions happens even when there's nothing to draw (no acquired
|
// Configuration) changed last frame - a no-op check on every frame
|
||||||
// image this frame), so edge detection (trigger/menu-button clicks)
|
// nothing moved.
|
||||||
// doesn't miss a frame. Only ever draws while the menu isn't visible
|
if (g_player_transform_dirty)
|
||||||
// (see xr_input.c), so it never needs to know about the menu's own
|
xr_session_rebuild_play_space();
|
||||||
// sub-rectangle below.
|
|
||||||
|
// Syncs actions and hit-tests/dispatches each hand's ray against
|
||||||
|
// keyboard/menu/game quad (see xr_input.c) before the game swapchain
|
||||||
|
// image is released below - this always runs, even when there's
|
||||||
|
// nothing to draw (no acquired image this frame), so edge detection
|
||||||
|
// (trigger/menu-button clicks) doesn't miss a frame. Any resulting
|
||||||
|
// laser-beam quads are submitted as their own composition layers
|
||||||
|
// further down, not drawn into the game swapchain itself.
|
||||||
if (xr_is_session_running())
|
if (xr_is_session_running())
|
||||||
xr_input_sync_and_draw(g_local_space, g_predicted_display_time, g_have_acquired_image);
|
xr_input_sync_and_draw(g_play_space, g_predicted_display_time,
|
||||||
|
g_have_acquired_game_image);
|
||||||
|
|
||||||
bool menuVisible = g_have_acquired_image && xr_menu_is_visible();
|
if (g_have_acquired_game_image)
|
||||||
if (menuVisible) {
|
xr_swapchain_release(g_instance, &g_game_swapchain);
|
||||||
// Switch viewport/scissor to the menu's own sub-rectangle of the
|
|
||||||
// same shared image (to the right of the game's own region, see
|
|
||||||
// g_swapchain's comment) and let xr_menu.c draw its content there -
|
|
||||||
// still the same framebuffer bound in xr_frame_begin(), just a
|
|
||||||
// different sub-rectangle of it.
|
|
||||||
glViewport(g_game_width, 0, g_menu_width, g_menu_height);
|
|
||||||
glScissor(g_game_width, 0, g_menu_width, g_menu_height);
|
|
||||||
xr_menu_render_if_visible();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (g_have_acquired_image) {
|
|
||||||
glDisable(GL_SCISSOR_TEST);
|
|
||||||
XrSwapchainImageReleaseInfo releaseInfo = {XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO};
|
|
||||||
xr_check(xrReleaseSwapchainImage(g_swapchain, &releaseInfo), "xrReleaseSwapchainImage");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!xr_is_session_running())
|
if (!xr_is_session_running())
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
// Up to 5 layers: the game screen (if a frame was actually rendered) -
|
||||||
|
// a flat quad, or a curved cylinder if the currently-active screen
|
||||||
|
// profile has a nonzero curve (see xr_get_game_quad_extent()'s own
|
||||||
|
// comment; xr_input.c's xr_input_try_game_quad() hit-tests whichever
|
||||||
|
// shape is actually submitted here, always agreeing since both read
|
||||||
|
// the same getter) - the menu launcher and keyboard overlays while
|
||||||
|
// visible - each gets its own acquire/render/release cycle against
|
||||||
|
// its own swapchain (see xr_overlay_render_and_build_layer()) any
|
||||||
|
// time before xrEndFrame, unlike the game screen there's no per-frame
|
||||||
|
// engine rendering to wrap around here, just each overlay's own blit -
|
||||||
|
// and, per hand, a laser-beam quad while that hand's ray is aimed at
|
||||||
|
// the keyboard, menu, or the game screen itself (see
|
||||||
|
// xr_input_get_beam_layer()).
|
||||||
|
float gameDistance, gameHalfWidth, gameHalfHeight, gameRadius, gameCentralAngle;
|
||||||
|
xr_get_game_quad_extent(&gameDistance, &gameHalfWidth, &gameHalfHeight, &gameRadius,
|
||||||
|
&gameCentralAngle);
|
||||||
|
bool gameScreenCurved = gameCentralAngle > 0.0f;
|
||||||
|
|
||||||
XrCompositionLayerQuad gameQuad = {XR_TYPE_COMPOSITION_LAYER_QUAD};
|
XrCompositionLayerQuad gameQuad = {XR_TYPE_COMPOSITION_LAYER_QUAD};
|
||||||
gameQuad.space = g_local_space;
|
XrCompositionLayerCylinderKHR gameCylinder = {XR_TYPE_COMPOSITION_LAYER_CYLINDER_KHR};
|
||||||
gameQuad.eyeVisibility = XR_EYE_VISIBILITY_BOTH;
|
if (gameScreenCurved) {
|
||||||
gameQuad.subImage.swapchain = g_swapchain;
|
gameCylinder.space = g_play_space;
|
||||||
gameQuad.subImage.imageRect.offset.x = 0;
|
gameCylinder.eyeVisibility = XR_EYE_VISIBILITY_BOTH;
|
||||||
gameQuad.subImage.imageRect.offset.y = 0;
|
gameCylinder.subImage.swapchain = g_game_swapchain.swapchain;
|
||||||
gameQuad.subImage.imageRect.extent.width = g_game_width;
|
gameCylinder.subImage.imageRect.extent.width = g_game_swapchain.width;
|
||||||
gameQuad.subImage.imageRect.extent.height = g_game_height;
|
gameCylinder.subImage.imageRect.extent.height = g_game_swapchain.height;
|
||||||
gameQuad.pose.orientation.w = 1.0f;
|
gameCylinder.pose.orientation.w = 1.0f;
|
||||||
gameQuad.pose.position.z = -QUAD_DISTANCE_METERS;
|
gameCylinder.pose.position.z = -gameDistance;
|
||||||
gameQuad.size.width = QUAD_WIDTH_METERS;
|
gameCylinder.radius = gameRadius;
|
||||||
gameQuad.size.height = QUAD_WIDTH_METERS * (float)g_game_height / (float)g_game_width;
|
gameCylinder.centralAngle = gameCentralAngle;
|
||||||
|
gameCylinder.aspectRatio = (float)g_game_swapchain.width / (float)g_game_swapchain.height;
|
||||||
|
} else {
|
||||||
|
gameQuad.space = g_play_space;
|
||||||
|
gameQuad.eyeVisibility = XR_EYE_VISIBILITY_BOTH;
|
||||||
|
gameQuad.subImage.swapchain = g_game_swapchain.swapchain;
|
||||||
|
gameQuad.subImage.imageRect.extent.width = g_game_swapchain.width;
|
||||||
|
gameQuad.subImage.imageRect.extent.height = g_game_swapchain.height;
|
||||||
|
gameQuad.pose.orientation.w = 1.0f;
|
||||||
|
gameQuad.pose.position.z = -gameDistance;
|
||||||
|
gameQuad.size.width = gameHalfWidth * 2.0f;
|
||||||
|
gameQuad.size.height = gameHalfHeight * 2.0f;
|
||||||
|
}
|
||||||
|
|
||||||
// Up to 2 layers: the game quad (if a frame was actually rendered) and,
|
const XrCompositionLayerBaseHeader *layers[5];
|
||||||
// while toggled on, the menu quad in front of it - see xr_input.c's
|
|
||||||
// menu_toggle handling. Both reference the SAME g_swapchain, just
|
|
||||||
// different imageRect sub-rectangles within it.
|
|
||||||
const XrCompositionLayerBaseHeader *layers[2];
|
|
||||||
uint32_t layerCount = 0;
|
uint32_t layerCount = 0;
|
||||||
if (g_have_acquired_image)
|
if (g_have_acquired_game_image)
|
||||||
layers[layerCount++] = (XrCompositionLayerBaseHeader *)&gameQuad;
|
layers[layerCount++] = gameScreenCurved ? (XrCompositionLayerBaseHeader *)&gameCylinder
|
||||||
|
: (XrCompositionLayerBaseHeader *)&gameQuad;
|
||||||
|
|
||||||
XrCompositionLayerQuad menuQuad = {XR_TYPE_COMPOSITION_LAYER_QUAD};
|
XrCompositionLayerQuad menuQuad;
|
||||||
if (menuVisible) {
|
if (g_frame_should_render && xr_overlay_render_and_build_layer(g_menu_overlay, &menuQuad)) {
|
||||||
float centerX, centerY, distance, halfWidth, halfHeight;
|
menuQuad.space = g_play_space;
|
||||||
xr_menu_get_quad_extent(¢erX, ¢erY, &distance, &halfWidth, &halfHeight);
|
|
||||||
menuQuad.space = g_local_space;
|
|
||||||
menuQuad.eyeVisibility = XR_EYE_VISIBILITY_BOTH;
|
menuQuad.eyeVisibility = XR_EYE_VISIBILITY_BOTH;
|
||||||
menuQuad.subImage.swapchain = g_swapchain;
|
|
||||||
menuQuad.subImage.imageRect.offset.x = g_game_width;
|
|
||||||
menuQuad.subImage.imageRect.offset.y = 0;
|
|
||||||
menuQuad.subImage.imageRect.extent.width = g_menu_width;
|
|
||||||
menuQuad.subImage.imageRect.extent.height = g_menu_height;
|
|
||||||
menuQuad.pose.orientation.w = 1.0f;
|
|
||||||
menuQuad.pose.position.x = centerX;
|
|
||||||
menuQuad.pose.position.y = centerY;
|
|
||||||
menuQuad.pose.position.z = -distance;
|
|
||||||
menuQuad.size.width = halfWidth * 2.0f;
|
|
||||||
menuQuad.size.height = halfHeight * 2.0f;
|
|
||||||
layers[layerCount++] = (XrCompositionLayerBaseHeader *)&menuQuad;
|
layers[layerCount++] = (XrCompositionLayerBaseHeader *)&menuQuad;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
XrCompositionLayerQuad keyboardQuad;
|
||||||
|
if (g_frame_should_render &&
|
||||||
|
xr_overlay_render_and_build_layer(g_keyboard_overlay, &keyboardQuad)) {
|
||||||
|
keyboardQuad.space = g_play_space;
|
||||||
|
keyboardQuad.eyeVisibility = XR_EYE_VISIBILITY_BOTH;
|
||||||
|
// Lets the keyboard sit on screen translucently while playing
|
||||||
|
// rather than fully hiding whatever's behind it - KeyboardOverlay
|
||||||
|
// writes real sub-255 alpha into the swapchain texture (see
|
||||||
|
// OverlayPanel.compositeAndPublish()), already in Android's
|
||||||
|
// default premultiplied format, which is what this flag's absence
|
||||||
|
// of UNPREMULTIPLIED_ALPHA_BIT assumes. The menu launcher above
|
||||||
|
// deliberately doesn't get this flag - it stays fully opaque.
|
||||||
|
keyboardQuad.layerFlags = XR_COMPOSITION_LAYER_BLEND_TEXTURE_SOURCE_ALPHA_BIT;
|
||||||
|
layers[layerCount++] = (XrCompositionLayerBaseHeader *)&keyboardQuad;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submitted last (frontmost) so a beam doesn't z-fight against the
|
||||||
|
// panel surface it's aimed at/terminates on - see
|
||||||
|
// xr_input_get_beam_layer(). Translucent for the same reason the
|
||||||
|
// keyboard is (see comment above).
|
||||||
|
XrCompositionLayerQuad beamQuad[2];
|
||||||
|
for (int hand = 0; hand < 2; hand++) {
|
||||||
|
if (xr_input_get_beam_layer(hand, &beamQuad[hand])) {
|
||||||
|
beamQuad[hand].space = g_play_space;
|
||||||
|
beamQuad[hand].eyeVisibility = XR_EYE_VISIBILITY_BOTH;
|
||||||
|
beamQuad[hand].layerFlags = XR_COMPOSITION_LAYER_BLEND_TEXTURE_SOURCE_ALPHA_BIT;
|
||||||
|
layers[layerCount++] = (XrCompositionLayerBaseHeader *)&beamQuad[hand];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
XrFrameEndInfo endInfo = {XR_TYPE_FRAME_END_INFO};
|
XrFrameEndInfo endInfo = {XR_TYPE_FRAME_END_INFO};
|
||||||
endInfo.displayTime = g_predicted_display_time;
|
endInfo.displayTime = g_predicted_display_time;
|
||||||
endInfo.environmentBlendMode = XR_ENVIRONMENT_BLEND_MODE_OPAQUE;
|
endInfo.environmentBlendMode = XR_ENVIRONMENT_BLEND_MODE_OPAQUE;
|
||||||
endInfo.layerCount = layerCount;
|
endInfo.layerCount = layerCount;
|
||||||
endInfo.layers = layerCount > 0 ? layers : NULL;
|
endInfo.layers = layerCount > 0 ? layers : NULL;
|
||||||
|
|
||||||
xr_check(xrEndFrame(g_session, &endInfo), "xrEndFrame");
|
xr_check(xrEndFrame(g_session, &endInfo), "xrEndFrame");
|
||||||
|
|
||||||
|
xr_session_update_frame_stats();
|
||||||
}
|
}
|
||||||
|
|
||||||
void xr_shutdown(void) {
|
void xr_shutdown(void) {
|
||||||
for (uint32_t i = 0; i < g_swapchain_image_count; i++) {
|
xr_swapchain_destroy(&g_game_swapchain);
|
||||||
if (g_swapchain_fbos[i] != 0)
|
|
||||||
glDeleteFramebuffers(1, &g_swapchain_fbos[i]);
|
|
||||||
}
|
|
||||||
g_swapchain_image_count = 0;
|
|
||||||
|
|
||||||
if (g_swapchain != XR_NULL_HANDLE)
|
|
||||||
xrDestroySwapchain(g_swapchain);
|
|
||||||
g_swapchain = XR_NULL_HANDLE;
|
|
||||||
|
|
||||||
if (g_local_space != XR_NULL_HANDLE)
|
if (g_local_space != XR_NULL_HANDLE)
|
||||||
xrDestroySpace(g_local_space);
|
xrDestroySpace(g_local_space);
|
||||||
g_local_space = XR_NULL_HANDLE;
|
g_local_space = XR_NULL_HANDLE;
|
||||||
|
|
||||||
|
if (g_play_space != XR_NULL_HANDLE)
|
||||||
|
xrDestroySpace(g_play_space);
|
||||||
|
g_play_space = XR_NULL_HANDLE;
|
||||||
|
|
||||||
// Before the session/instance they were created from.
|
// Before the session/instance they were created from.
|
||||||
xr_input_shutdown();
|
xr_input_shutdown();
|
||||||
xr_menu_shutdown();
|
xr_overlay_destroy(g_menu_overlay);
|
||||||
|
g_menu_overlay = NULL;
|
||||||
|
xr_overlay_destroy(g_keyboard_overlay);
|
||||||
|
g_keyboard_overlay = NULL;
|
||||||
|
|
||||||
if (g_session != XR_NULL_HANDLE)
|
if (g_session != XR_NULL_HANDLE)
|
||||||
xrDestroySession(g_session);
|
xrDestroySession(g_session);
|
||||||
@@ -546,10 +824,74 @@ void xr_shutdown(void) {
|
|||||||
|
|
||||||
g_session_state = XR_SESSION_STATE_UNKNOWN;
|
g_session_state = XR_SESSION_STATE_UNKNOWN;
|
||||||
g_session_running = false;
|
g_session_running = false;
|
||||||
|
|
||||||
|
g_performance_metrics_supported = false;
|
||||||
|
g_xrSetPerformanceMetricsStateMETA = NULL;
|
||||||
|
g_xrQueryPerformanceMetricsCounterMETA = NULL;
|
||||||
|
g_cpu_utilization_path = XR_NULL_PATH;
|
||||||
|
g_gpu_utilization_path = XR_NULL_PATH;
|
||||||
|
|
||||||
|
g_cylinder_layer_supported = false;
|
||||||
|
// g_screen_width_m/g_screen_curve_deg/g_screen_distance_m deliberately
|
||||||
|
// NOT reset here - same reasoning as xr_session_set_player_transform()'s
|
||||||
|
// own state: it's meant to persist across a shutdown/reinit within the
|
||||||
|
// same process (e.g. an Android lifecycle pause/resume), not just
|
||||||
|
// within one XR session.
|
||||||
}
|
}
|
||||||
|
|
||||||
void xr_get_game_quad_extent(float *distance_m, float *half_width_m, float *half_height_m) {
|
void xr_session_set_game_screen_shape(float width_m, float curve_deg, float distance_m) {
|
||||||
*distance_m = QUAD_DISTANCE_METERS;
|
g_screen_width_m = width_m;
|
||||||
*half_width_m = QUAD_WIDTH_METERS * 0.5f;
|
g_screen_curve_deg = curve_deg;
|
||||||
*half_height_m = QUAD_WIDTH_METERS * 0.5f * (float)g_game_height / (float)g_game_width;
|
g_screen_distance_m = distance_m;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single source of truth for the game screen's current shape, read by
|
||||||
|
// both xr_frame_end() (to build/submit the actual layer) and
|
||||||
|
// xr_input_try_game_quad() (xr_input.c, to hit-test the laser pointer
|
||||||
|
// against it). half_width_m is meaningful only when *central_angle_rad is
|
||||||
|
// 0 (flat); a curved screen's horizontal extent is bounded by
|
||||||
|
// *central_angle_rad instead, with *half_height_m always the vertical
|
||||||
|
// bound either way.
|
||||||
|
void xr_get_game_quad_extent(float *distance_m, float *half_width_m, float *half_height_m,
|
||||||
|
float *radius_m, float *central_angle_rad) {
|
||||||
|
float curveDeg = g_cylinder_layer_supported ? g_screen_curve_deg : 0.0f;
|
||||||
|
float aspect = (float)g_game_swapchain.height / (float)g_game_swapchain.width;
|
||||||
|
|
||||||
|
*distance_m = g_screen_distance_m;
|
||||||
|
*half_width_m = g_screen_width_m * 0.5f;
|
||||||
|
*half_height_m = g_screen_width_m * 0.5f * aspect;
|
||||||
|
|
||||||
|
if (curveDeg > 0.0f) {
|
||||||
|
float theta = curveDeg * QS_DEG_TO_RAD;
|
||||||
|
*central_angle_rad = theta;
|
||||||
|
*radius_m = g_screen_width_m / theta;
|
||||||
|
} else {
|
||||||
|
*central_angle_rad = 0.0f;
|
||||||
|
*radius_m = 0.0f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void xr_get_game_swapchain_size(int *width, int *height) {
|
||||||
|
*width = g_game_swapchain.width;
|
||||||
|
*height = g_game_swapchain.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
void xr_get_game_resolution(int *width, int *height) {
|
||||||
|
// Not g_game_swapchain.width/height: that's the fixed physical size of
|
||||||
|
// the swapchain image the game is composited into (see xr_init()) -
|
||||||
|
// android_draw_surface_as_quad() (OpenGL.cc, engine-patches/11) always
|
||||||
|
// stretch-blits drawSurface to fill that image regardless of
|
||||||
|
// drawSurface's own pixel size, so the swapchain's size has no relation
|
||||||
|
// to the coordinate space MouseX/MouseY actually live in. gScreenWide/
|
||||||
|
// gScreenHigh (InitMac.h) is drawSurface's current size - the
|
||||||
|
// resolution the in-game options menu can change at runtime
|
||||||
|
// (ShockBitmap.c's ChangeScreenSize()) independently of the swapchain,
|
||||||
|
// which is created once at launch and never resized. Using the fixed
|
||||||
|
// swapchain size here after such a change mapped the laser pointer's
|
||||||
|
// quad-space hit into the wrong coordinate space entirely, moving
|
||||||
|
// MouseX/MouseY out of the engine's actual screen bounds - which is
|
||||||
|
// what made the cursor disappear and become unclickable.
|
||||||
|
extern int32_t gScreenWide, gScreenHigh;
|
||||||
|
*width = gScreenWide;
|
||||||
|
*height = gScreenHigh;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,43 @@
|
|||||||
// Minimal OpenXR bring-up for questshock's immersive Quest build. Keeps the
|
// Minimal OpenXR bring-up for questshock's immersive Quest build. Keeps the
|
||||||
// game's own rendering untouched (see android/engine-patches/
|
// game's own rendering untouched (see android/engine-patches/
|
||||||
// 11-android-openxr-present.patch) - this module only owns the OpenXR
|
// 11-android-openxr-present.patch) - this module only owns the OpenXR
|
||||||
// instance/session/swapchain and the final "submit a quad layer instead of
|
// instance/session/swapchains and the final "submit quad layers instead of
|
||||||
// presenting to a window" step. No stereo rendering, no controller input
|
// presenting to a window" step. No stereo rendering - the game composite is
|
||||||
// yet (see the plan's steps B/C/D for those) - the game composite is shown
|
// shown as a single flat quad floating in front of the viewer.
|
||||||
// as a single flat quad floating in front of the viewer.
|
|
||||||
#ifndef QUESTSHOCK_XR_SESSION_H
|
#ifndef QUESTSHOCK_XR_SESSION_H
|
||||||
#define QUESTSHOCK_XR_SESSION_H
|
#define QUESTSHOCK_XR_SESSION_H
|
||||||
|
|
||||||
#include <stdbool.h>
|
#include <stdbool.h>
|
||||||
|
|
||||||
|
#include "xr_overlay.h"
|
||||||
|
|
||||||
#ifdef __cplusplus
|
#ifdef __cplusplus
|
||||||
extern "C" {
|
extern "C" {
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Call once, right after init_opengl() (OpenGL.cc) has created and made
|
// Call once, right after init_opengl() (OpenGL.cc) has created and made
|
||||||
// current the GL context SDL/gl4es already use - creates the OpenXR
|
// current the GL context SDL/gl4es already use - creates the OpenXR
|
||||||
// instance/session sharing that same EGL display/context, plus one
|
// instance/session sharing that same EGL display/context, plus the game
|
||||||
// swapchain sized to the game's logical resolution (game_width/height, i.e.
|
// quad's own swapchain, sized to the game's logical resolution
|
||||||
// grd_cap->w/h - see Shock.c's InitSDL()). Returns false if OpenXR bring-up
|
// (game_width/height, i.e. grd_cap->w/h - see Shock.c's InitSDL()). Also
|
||||||
// failed (e.g. no runtime installed) - callers should fall back to the
|
// creates the menu launcher and keyboard overlay quads (see xr_overlay.h) -
|
||||||
// existing window-present path in that case.
|
// two independent XrOverlay instances, each with their own swapchain,
|
||||||
|
// visibility, and position, retrievable via xr_session_get_menu_overlay()/
|
||||||
|
// xr_session_get_keyboard_overlay() below. Returns false if OpenXR
|
||||||
|
// bring-up failed (e.g. no runtime installed) - callers should fall back
|
||||||
|
// to the existing window-present path in that case.
|
||||||
bool xr_init(int game_width, int game_height);
|
bool xr_init(int game_width, int game_height);
|
||||||
|
|
||||||
|
// The menu launcher (small "Keyboard" button panel, toggled by the
|
||||||
|
// controller's menu button) and keyboard (the on-screen key grid, shown/
|
||||||
|
// hidden independently via MenuOverlay's "Keyboard" button and
|
||||||
|
// KeyboardOverlay's own Close button) overlay quads - used by xr_input.c
|
||||||
|
// for ray/quad hit-testing and touch/cursor dispatch, and by
|
||||||
|
// questshock_native.c's JNI glue (nativeShowKeyboard()/
|
||||||
|
// nativeRequestClose()). NULL if xr_init() failed before creating them.
|
||||||
|
XrOverlay *xr_session_get_menu_overlay(void);
|
||||||
|
XrOverlay *xr_session_get_keyboard_overlay(void);
|
||||||
|
|
||||||
// Pumps XR session-state events. Call once per frame, before
|
// Pumps XR session-state events. Call once per frame, before
|
||||||
// xr_frame_begin(). Must still be called even when xr_init() returned
|
// xr_frame_begin(). Must still be called even when xr_init() returned
|
||||||
// false (no-op in that case).
|
// false (no-op in that case).
|
||||||
@@ -34,26 +49,122 @@ void xr_poll_events(void);
|
|||||||
bool xr_is_session_running(void);
|
bool xr_is_session_running(void);
|
||||||
|
|
||||||
// Begins the XR frame (xrWaitFrame/xrBeginFrame) and, if the runtime wants
|
// Begins the XR frame (xrWaitFrame/xrBeginFrame) and, if the runtime wants
|
||||||
// this frame rendered, acquires the next swapchain image and binds its
|
// this frame rendered, acquires the game quad's next swapchain image and
|
||||||
// framebuffer as the current render target - ready for the caller to draw
|
// binds its framebuffer as the current render target - ready for the
|
||||||
// into exactly as it would have drawn to the default framebuffer. Returns
|
// caller to draw into exactly as it would have drawn to the default
|
||||||
// true if the caller should draw this frame; xr_frame_end() must be called
|
// framebuffer. Returns true if the caller should draw this frame;
|
||||||
// unconditionally afterward either way (a begun XR frame must always be
|
// xr_frame_end() must be called unconditionally afterward either way (a
|
||||||
// ended, rendered or not).
|
// begun XR frame must always be ended, rendered or not).
|
||||||
bool xr_frame_begin(void);
|
bool xr_frame_begin(void);
|
||||||
|
|
||||||
// Releases the swapchain image (if one was acquired this frame) and
|
// Releases the game quad's swapchain image (if one was acquired this
|
||||||
// submits it as a single XrCompositionLayerQuad positioned in front of the
|
// frame), then does the same acquire/render/release cycle for the menu
|
||||||
// local reference space's origin, then ends the XR frame.
|
// launcher and keyboard overlay quads via xr_overlay_render_and_build_layer()
|
||||||
|
// for whichever of them is currently visible, and submits whichever of the
|
||||||
|
// three quads actually rendered this frame as composition layers
|
||||||
|
// positioned in front of the play space's origin (see
|
||||||
|
// xr_session_set_player_transform() above), then ends the XR frame.
|
||||||
void xr_frame_end(void);
|
void xr_frame_end(void);
|
||||||
|
|
||||||
void xr_shutdown(void);
|
void xr_shutdown(void);
|
||||||
|
|
||||||
// Local-space geometry of the single game quad xr_frame_end() submits
|
// Play-space geometry of the single game screen xr_frame_end() submits
|
||||||
// (distance in front of the local space origin, half-width/half-height,
|
// (distance in front of the play space origin, half-width/half-height,
|
||||||
// all in meters) - shared with xr_input.c's ray/quad hit-testing so the
|
// all in meters) - shared with xr_input.c's ray/quad hit-testing so the
|
||||||
// laser pointer always matches whatever's actually visible.
|
// laser pointer always matches whatever's actually visible. *radius_m/
|
||||||
void xr_get_game_quad_extent(float *distance_m, float *half_width_m, float *half_height_m);
|
// *central_angle_rad are 0 when flat, in which case *half_width_m is the
|
||||||
|
// horizontal extent; otherwise the screen is a curved cylinder segment
|
||||||
|
// and the caller uses *central_angle_rad instead of *half_width_m for the
|
||||||
|
// horizontal bound (*half_height_m stays meaningful either way). Curving
|
||||||
|
// is currently unused - qs_config.c has no config surface for it - but
|
||||||
|
// kept working so it can be re-enabled later.
|
||||||
|
void xr_get_game_quad_extent(float *distance_m, float *half_width_m, float *half_height_m,
|
||||||
|
float *radius_m, float *central_angle_rad);
|
||||||
|
|
||||||
|
// Sets the game screen's current physical width (meters) and curve (wrap
|
||||||
|
// angle in degrees, 0 = flat) - a plain state setter, no XR calls, same
|
||||||
|
// category as xr_session_set_player_transform() above (safe to call
|
||||||
|
// before xr_init() has even run). Read by both xr_frame_end() (to build/
|
||||||
|
// submit the actual layer) and, via xr_get_game_quad_extent() above,
|
||||||
|
// xr_input.c's hit-testing. Driven by qs_config.c's
|
||||||
|
// qs_screen_profile_notify_loop_mode(), which decides which of the
|
||||||
|
// "Initial Screen"/"Game Screen" config profiles (see CONFIG.md) is
|
||||||
|
// currently active - this function itself has no notion of "profiles",
|
||||||
|
// only "what shape is current".
|
||||||
|
void xr_session_set_game_screen_shape(float width_m, float curve_deg, float distance_m);
|
||||||
|
|
||||||
|
// The engine's current screen resolution (gScreenWide/gScreenHigh,
|
||||||
|
// InitMac.h) - not the game quad's own swapchain size, which is fixed at
|
||||||
|
// launch and never changes - the logical coordinate space xr_input.c needs
|
||||||
|
// to convert a game-quad ray hit's normalized u,v into engine mouse
|
||||||
|
// coordinates (see xr_mouse.h). Can change at runtime via the in-game
|
||||||
|
// options menu (ShockBitmap.c's ChangeScreenSize()), independently of the
|
||||||
|
// swapchain.
|
||||||
|
void xr_get_game_resolution(int *width, int *height);
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// the second frame has run (nothing to compute a delta from yet).
|
||||||
|
float xr_session_get_fps(void);
|
||||||
|
float xr_session_get_frame_ms(void);
|
||||||
|
|
||||||
|
// Device-wide CPU/GPU utilization percentage (0-100), via the
|
||||||
|
// XR_META_performance_metrics extension - same per-frame update cadence as
|
||||||
|
// the FPS/frame-time getters above. Returns -1.0f if the extension isn't
|
||||||
|
// supported by the runtime, or its cpu_utilization/gpu_utilization counter
|
||||||
|
// couldn't be found among the ones it advertises.
|
||||||
|
float xr_session_get_cpu_utilization(void);
|
||||||
|
float xr_session_get_gpu_utilization(void);
|
||||||
|
|
||||||
|
// The player's virtual position/orientation - horizontal-plane only (x/z
|
||||||
|
// plus a yaw rotation about the vertical axis); height always comes from
|
||||||
|
// the real tracked HMD pose, never from this. Everything the app submits
|
||||||
|
// (game/menu/keyboard/beam quads) and every hand/head pose it locates is
|
||||||
|
// expressed relative to a second reference space, derived from the real
|
||||||
|
// LOCAL space by this transform's inverse (see xr_session.c's own
|
||||||
|
// xr_session_rebuild_play_space()) - so changing it moves/turns the player
|
||||||
|
// relative to those otherwise-fixed quads, letting them walk up to and
|
||||||
|
// square up with the game quad ("virtual screen") without needing any
|
||||||
|
// physical room-scale space to do it in.
|
||||||
|
//
|
||||||
|
// xr_session_set_player_transform() only stores the three floats (no XR
|
||||||
|
// calls) - safe to call before xr_init() has even run, which is how
|
||||||
|
// qs_config_load_startup() seeds the saved position before the OpenXR
|
||||||
|
// session/spaces exist yet (xr_init() picks it up when it creates its own
|
||||||
|
// spaces); also used live by xr_input.c's thumbstick locomotion and
|
||||||
|
// "Reset Position" recenter (xr_input_recenter_player_to_screen()) and
|
||||||
|
// qs_config.c's "Load Configuration", and read back by "Save Configuration"
|
||||||
|
// via the getter.
|
||||||
|
void xr_session_set_player_transform(float x, float z, float yaw_deg);
|
||||||
|
void xr_session_get_player_transform(float *x, float *z, float *yaw_deg);
|
||||||
|
|
||||||
|
// The real, physical tracking-origin reference space - used only by
|
||||||
|
// xr_input_recenter_player_to_screen() (xr_input.c), which needs the
|
||||||
|
// player's CURRENT real head pose (not one already bent through the
|
||||||
|
// existing play-space transform - see xr_session_set_player_transform()'s
|
||||||
|
// own comment above) to compute a brand new one. Every other consumer in
|
||||||
|
// this app (rendering, hit-testing) uses the play space instead - see
|
||||||
|
// xr_session_rebuild_play_space()'s own comment for why this one stays a
|
||||||
|
// separate, never-swapped-out space.
|
||||||
|
XrSpace xr_session_get_local_space(void);
|
||||||
|
|
||||||
|
// The most recent xrWaitFrame-predicted display time (xr_frame_begin()'s
|
||||||
|
// own g_predicted_display_time) - for the same reason as
|
||||||
|
// xr_session_get_local_space() above, xr_input_recenter_player_to_screen()
|
||||||
|
// needs a valid XrTime to locate a space with but isn't itself called from
|
||||||
|
// the normal per-frame xr_frame_end()/xr_input_sync_and_draw() call chain
|
||||||
|
// that already threads one through (it's triggered by a button press,
|
||||||
|
// dispatched via qs_config.c's cross-thread event queue instead). A frame
|
||||||
|
// or so of staleness doesn't matter for a one-shot recenter. 0 before the
|
||||||
|
// first frame has ever begun.
|
||||||
|
XrTime xr_session_get_predicted_display_time(void);
|
||||||
|
|
||||||
#ifdef __cplusplus
|
#ifdef __cplusplus
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
#include "xr_swapchain.h"
|
||||||
|
|
||||||
|
#include <dlfcn.h>
|
||||||
|
|
||||||
|
#include <android/log.h>
|
||||||
|
|
||||||
|
#define TAG "QuestShock"
|
||||||
|
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__)
|
||||||
|
|
||||||
|
static bool xr_check(XrInstance instance, XrResult result, const char *what) {
|
||||||
|
if (XR_SUCCEEDED(result))
|
||||||
|
return true;
|
||||||
|
char resultString[XR_MAX_RESULT_STRING_SIZE] = {0};
|
||||||
|
if (instance != XR_NULL_HANDLE)
|
||||||
|
xrResultToString(instance, result, resultString);
|
||||||
|
LOGE("XR: %s failed: %s (%d)", what, resultString[0] ? resultString : "?", (int)result);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This file's other GL calls otherwise resolve to gl4es (the only GL
|
||||||
|
// symbol provider linked into this binary - see android/engine-patches/
|
||||||
|
// 02-android-opengl-es.patch), which is fine for anything shared with the
|
||||||
|
// engine's own gl4es-routed rendering. But the swapchain images OpenXR
|
||||||
|
// hands us are real driver texture objects gl4es never created itself,
|
||||||
|
// and gl4es's own glFramebufferTexture2D can't attach a texture it has no
|
||||||
|
// tracked metadata for. So the FBO *container* is created via gl4es's own
|
||||||
|
// glGenFramebuffers/glBindFramebuffer (so gl4es recognizes the id as its
|
||||||
|
// own and its own per-frame glBindFramebuffer succeeds), while the
|
||||||
|
// texture-attach step - the specifically foreign part - goes through the
|
||||||
|
// real driver directly, via dlsym against libGLESv2.so.
|
||||||
|
typedef void (*PFNQSFRAMEBUFFERTEXTURE2D)(GLenum, GLenum, GLenum, GLuint, GLint);
|
||||||
|
typedef GLenum (*PFNQSCHECKFRAMEBUFFERSTATUS)(GLenum);
|
||||||
|
|
||||||
|
static PFNQSFRAMEBUFFERTEXTURE2D real_glFramebufferTexture2D;
|
||||||
|
static PFNQSCHECKFRAMEBUFFERSTATUS real_glCheckFramebufferStatus;
|
||||||
|
static bool g_real_gles_loaded = false;
|
||||||
|
|
||||||
|
static bool xr_swapchain_load_real_gles(void) {
|
||||||
|
if (g_real_gles_loaded)
|
||||||
|
return true;
|
||||||
|
void *lib = dlopen("libGLESv2.so", RTLD_NOW | RTLD_LOCAL);
|
||||||
|
if (lib == NULL) {
|
||||||
|
LOGE("XR: swapchain dlopen(libGLESv2.so) failed: %s", dlerror());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
real_glFramebufferTexture2D = (PFNQSFRAMEBUFFERTEXTURE2D)dlsym(lib, "glFramebufferTexture2D");
|
||||||
|
real_glCheckFramebufferStatus =
|
||||||
|
(PFNQSCHECKFRAMEBUFFERSTATUS)dlsym(lib, "glCheckFramebufferStatus");
|
||||||
|
g_real_gles_loaded = real_glFramebufferTexture2D && real_glCheckFramebufferStatus;
|
||||||
|
return g_real_gles_loaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool xr_swapchain_create(XrInstance instance, XrSession session, int64_t format, int width,
|
||||||
|
int height, XrSwapchainState *out) {
|
||||||
|
if (!xr_swapchain_load_real_gles()) {
|
||||||
|
LOGE("XR: swapchain couldn't resolve real GLES FBO functions via dlsym");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
out->width = width;
|
||||||
|
out->height = height;
|
||||||
|
|
||||||
|
XrSwapchainCreateInfo swapchainInfo = {XR_TYPE_SWAPCHAIN_CREATE_INFO};
|
||||||
|
swapchainInfo.usageFlags =
|
||||||
|
XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT | XR_SWAPCHAIN_USAGE_SAMPLED_BIT;
|
||||||
|
swapchainInfo.format = format;
|
||||||
|
swapchainInfo.sampleCount = 1;
|
||||||
|
swapchainInfo.width = (uint32_t)width;
|
||||||
|
swapchainInfo.height = (uint32_t)height;
|
||||||
|
swapchainInfo.faceCount = 1;
|
||||||
|
swapchainInfo.arraySize = 1;
|
||||||
|
swapchainInfo.mipCount = 1;
|
||||||
|
if (!xr_check(instance, xrCreateSwapchain(session, &swapchainInfo, &out->swapchain),
|
||||||
|
"xrCreateSwapchain"))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
uint32_t imageCount = 0;
|
||||||
|
xrEnumerateSwapchainImages(out->swapchain, 0, &imageCount, NULL);
|
||||||
|
if (imageCount > XR_SWAPCHAIN_MAX_IMAGES) {
|
||||||
|
LOGE("XR: swapchain reports %u images, only room for %d", imageCount,
|
||||||
|
XR_SWAPCHAIN_MAX_IMAGES);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Zero-initialized, not just `.type` set per element - these structs
|
||||||
|
// also carry a `next` field the runtime may read, and an uninitialized
|
||||||
|
// stack array would leave it as garbage.
|
||||||
|
XrSwapchainImageOpenGLESKHR images[XR_SWAPCHAIN_MAX_IMAGES] = {0};
|
||||||
|
for (uint32_t i = 0; i < imageCount; i++)
|
||||||
|
images[i].type = XR_TYPE_SWAPCHAIN_IMAGE_OPENGL_ES_KHR;
|
||||||
|
if (!xr_check(instance,
|
||||||
|
xrEnumerateSwapchainImages(out->swapchain, imageCount, &imageCount,
|
||||||
|
(XrSwapchainImageBaseHeader *)images),
|
||||||
|
"xrEnumerateSwapchainImages"))
|
||||||
|
return false;
|
||||||
|
out->image_count = imageCount;
|
||||||
|
|
||||||
|
// Wraps each swapchain-provided texture in its own framebuffer, matching
|
||||||
|
// how OpenGL.cc's own CreateFrameBuffer() wraps backupBuffer - just
|
||||||
|
// without a depth/stencil attachment, since the final composite draw
|
||||||
|
// (see opengl_swap_and_restore) never needs one. The Gen/Bind calls are
|
||||||
|
// gl4es's own (linked, not dlsym'd) - see the comment above
|
||||||
|
// real_glFramebufferTexture2D for why.
|
||||||
|
bool all_complete = true;
|
||||||
|
for (uint32_t i = 0; i < imageCount; i++) {
|
||||||
|
glGenFramebuffers(1, &out->fbos[i]);
|
||||||
|
glBindFramebuffer(GL_FRAMEBUFFER, out->fbos[i]);
|
||||||
|
real_glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D,
|
||||||
|
images[i].image, 0);
|
||||||
|
GLenum status = real_glCheckFramebufferStatus(GL_FRAMEBUFFER);
|
||||||
|
if (status != GL_FRAMEBUFFER_COMPLETE) {
|
||||||
|
LOGE("XR: swapchain FBO %u incomplete: 0x%x", i, status);
|
||||||
|
all_complete = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
||||||
|
return all_complete;
|
||||||
|
}
|
||||||
|
|
||||||
|
void xr_swapchain_destroy(XrSwapchainState *state) {
|
||||||
|
for (uint32_t i = 0; i < state->image_count; i++) {
|
||||||
|
if (state->fbos[i] != 0)
|
||||||
|
glDeleteFramebuffers(1, &state->fbos[i]);
|
||||||
|
}
|
||||||
|
state->image_count = 0;
|
||||||
|
if (state->swapchain != XR_NULL_HANDLE)
|
||||||
|
xrDestroySwapchain(state->swapchain);
|
||||||
|
state->swapchain = XR_NULL_HANDLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acquires the next image of the given swapchain, waits for the runtime to
|
||||||
|
// finish with it, and binds its framebuffer (sized to exactly fill it) as
|
||||||
|
// the current render target.
|
||||||
|
bool xr_swapchain_acquire(XrInstance instance, XrSwapchainState *state) {
|
||||||
|
uint32_t imageIndex = 0;
|
||||||
|
XrSwapchainImageAcquireInfo acquireInfo = {XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO};
|
||||||
|
if (!xr_check(instance, xrAcquireSwapchainImage(state->swapchain, &acquireInfo, &imageIndex),
|
||||||
|
"xrAcquireSwapchainImage"))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
XrSwapchainImageWaitInfo waitImageInfo = {XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO};
|
||||||
|
waitImageInfo.timeout = XR_INFINITE_DURATION;
|
||||||
|
if (!xr_check(instance, xrWaitSwapchainImage(state->swapchain, &waitImageInfo),
|
||||||
|
"xrWaitSwapchainImage"))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// gl4es's own (linked, not dlsym'd) bind - this targets an FBO id gl4es
|
||||||
|
// itself created (see xr_swapchain_create()), so its own "current FBO"
|
||||||
|
// bookkeeping updates correctly and its immediate-mode draw calls
|
||||||
|
// (android_draw_surface_as_quad()) land in the right place.
|
||||||
|
glBindFramebuffer(GL_FRAMEBUFFER, state->fbos[imageIndex]);
|
||||||
|
glViewport(0, 0, state->width, state->height);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void xr_swapchain_release(XrInstance instance, XrSwapchainState *state) {
|
||||||
|
XrSwapchainImageReleaseInfo releaseInfo = {XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO};
|
||||||
|
xr_check(instance, xrReleaseSwapchainImage(state->swapchain, &releaseInfo),
|
||||||
|
"xrReleaseSwapchainImage");
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// Generic OpenXR swapchain + per-image GL framebuffer helper, shared by
|
||||||
|
// every quad this app submits - the game quad (xr_session.c) and every
|
||||||
|
// xr_overlay.c instance (menu launcher, keyboard) - since they all need
|
||||||
|
// exactly the same setup, just at their own size/format.
|
||||||
|
#ifndef QUESTSHOCK_XR_SWAPCHAIN_H
|
||||||
|
#define QUESTSHOCK_XR_SWAPCHAIN_H
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#include <EGL/egl.h>
|
||||||
|
#include <GLES3/gl3.h>
|
||||||
|
#include <jni.h>
|
||||||
|
|
||||||
|
#define XR_USE_PLATFORM_ANDROID 1
|
||||||
|
#define XR_USE_GRAPHICS_API_OPENGL_ES 1
|
||||||
|
#include <openxr/openxr.h>
|
||||||
|
#include <openxr/openxr_platform.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Up to this many swapchain images/FBOs - real runtimes report small counts
|
||||||
|
// (2-4); this is just a fixed upper bound for the cache arrays below.
|
||||||
|
#define XR_SWAPCHAIN_MAX_IMAGES 8
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
XrSwapchain swapchain;
|
||||||
|
GLuint fbos[XR_SWAPCHAIN_MAX_IMAGES];
|
||||||
|
uint32_t image_count;
|
||||||
|
int width;
|
||||||
|
int height;
|
||||||
|
} XrSwapchainState;
|
||||||
|
|
||||||
|
// Creates the swapchain and wraps each of its images in its own GL
|
||||||
|
// framebuffer, ready to bind and render into directly. instance is only
|
||||||
|
// used to log a human-readable error string on failure.
|
||||||
|
bool xr_swapchain_create(XrInstance instance, XrSession session, int64_t format, int width,
|
||||||
|
int height, XrSwapchainState *out);
|
||||||
|
|
||||||
|
void xr_swapchain_destroy(XrSwapchainState *state);
|
||||||
|
|
||||||
|
// Acquires the next image, waits for the runtime to finish with it, and
|
||||||
|
// binds its framebuffer (sized to exactly fill it) as the current render
|
||||||
|
// target.
|
||||||
|
bool xr_swapchain_acquire(XrInstance instance, XrSwapchainState *state);
|
||||||
|
|
||||||
|
void xr_swapchain_release(XrInstance instance, XrSwapchainState *state);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,586 @@
|
|||||||
|
package de.ladkau.questshock;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.util.TypedValue;
|
||||||
|
import android.view.Gravity;
|
||||||
|
import android.view.KeyEvent;
|
||||||
|
import android.view.MotionEvent;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.Button;
|
||||||
|
import android.widget.LinearLayout;
|
||||||
|
import android.widget.TextView;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import org.libsdl.app.SDLActivity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The on-screen keyboard quad (see OverlayPanel for the shared off-screen-
|
||||||
|
* render/touch/cursor plumbing this builds on) - a hand-built, full
|
||||||
|
* US-layout key grid (there's no system IME to borrow once immersive) plus
|
||||||
|
* a text field echoing what's been sent. Every key that reaches the game
|
||||||
|
* (everything but Close/Shift/Ctrl/Alt, which are local UI actions - see
|
||||||
|
* handleKeyDown()/handleKeyUp() and handleCharKeyDown()/handleCharKeyUp())
|
||||||
|
* is driven by that key button's own ACTION_DOWN/ACTION_UP (see
|
||||||
|
* charKeyTouchListener()/controlKeyTouchListener()), which
|
||||||
|
* OverlayPanel.handleTouch() dispatches for exactly as long as the
|
||||||
|
* controller's trigger is physically held while aimed at it - so a key
|
||||||
|
* held on this keyboard behaves like a real key held on a physical one,
|
||||||
|
* not an instant tap. Non-printable keys go through an
|
||||||
|
* SDLActivity.onNativeKeyDown()/onNativeKeyUp() pair, the same one a
|
||||||
|
* physical Bluetooth keyboard's presses already drive; printable keys go
|
||||||
|
* through a synthesized SDL_TEXTINPUT/SDL_KEYUP pair pushed from native
|
||||||
|
* code (see nativeSendPrintableCharDown()/Up() in questshock_native.c) -
|
||||||
|
* except while a Ctrl/Alt "armed" modifier is active, when they go through
|
||||||
|
* the same onNativeKeyDown()/onNativeKeyUp() path instead, so the modifier
|
||||||
|
* carries across (see toggleModifier()). Shift is a purely local layer
|
||||||
|
* toggle (uppercase/symbols vs lowercase/numbers), never itself sent to the
|
||||||
|
* game.
|
||||||
|
*
|
||||||
|
* This is a fully independent overlay quad from MenuOverlay's small
|
||||||
|
* launcher - opened via that launcher's "Keyboard" button
|
||||||
|
* (nativeShowKeyboard()) but otherwise self-contained: once open, the
|
||||||
|
* controller's menu button (which only ever affects the menu launcher, see
|
||||||
|
* xr_input.c) doesn't hide it, so it stays up as a standing input panel
|
||||||
|
* while actually playing rather than a modal you open and close. The only
|
||||||
|
* way to hide it is its own title bar's Close button (buildTitleBar()),
|
||||||
|
* which also doubles as a drag handle for repositioning it (handled
|
||||||
|
* entirely on the native side - see xr_input.c - since it never reaches
|
||||||
|
* this class's own touch dispatch). contentAlpha() renders the whole panel
|
||||||
|
* semi-transparent so whatever's behind it stays visible while it's up.
|
||||||
|
*/
|
||||||
|
public class KeyboardOverlay extends OverlayPanel {
|
||||||
|
// Matches xr_session.c's KEYBOARD_WIDTH/KEYBOARD_HEIGHT swapchain size -
|
||||||
|
// fixed, not tied to any real display metric, since this tree is never
|
||||||
|
// shown in a real window.
|
||||||
|
private static final int WIDTH = 1024;
|
||||||
|
private static final int HEIGHT = 768;
|
||||||
|
|
||||||
|
private static final int TYPED_TEXT_HEIGHT = 100;
|
||||||
|
|
||||||
|
// Height of the title bar (buildTitleBar()) and width of its Close
|
||||||
|
// button - xr_input.c's KEYBOARD_TITLE_BAR_V_FRACTION/
|
||||||
|
// KEYBOARD_CLOSE_BUTTON_U_FRACTION must be kept in sync with these (as
|
||||||
|
// fractions of WIDTH/HEIGHT above), since the drag-handle-vs-Close-
|
||||||
|
// button-vs-rest-of-the-keyboard decision happens entirely on the
|
||||||
|
// native side, before a hit ever reaches this class's own touch
|
||||||
|
// dispatch.
|
||||||
|
private static final int TITLE_BAR_HEIGHT = 90;
|
||||||
|
private static final int CLOSE_BUTTON_WIDTH = 160;
|
||||||
|
|
||||||
|
// ~70% opaque - readable but lets whatever's behind the panel (the game
|
||||||
|
// quad, or empty space) show through while it's left up during play.
|
||||||
|
private static final int PANEL_ALPHA = 180;
|
||||||
|
|
||||||
|
private static KeyboardOverlay instance;
|
||||||
|
|
||||||
|
private TextView typedTextView;
|
||||||
|
private final StringBuilder typedText = new StringBuilder();
|
||||||
|
|
||||||
|
// Title bar chrome text - the only pieces of this panel that get
|
||||||
|
// translated (see Strings.java's own comment on why the actual key
|
||||||
|
// labels don't). Set from MenuOverlay.nativeGetLanguage() at the top of
|
||||||
|
// buildContent() (see OverlayPanel's constructor note on why a field a
|
||||||
|
// subclass's buildContent() depends on can't rely on normal field-
|
||||||
|
// initializer timing) and kept in sync afterward by applyTranslations()
|
||||||
|
// below, called from MenuOverlay's own periodic language-refresh check.
|
||||||
|
private int currentLanguage = Strings.ENGLISH;
|
||||||
|
private TextView titleLabel;
|
||||||
|
private Button closeButton;
|
||||||
|
|
||||||
|
// Shift is a local-only layer toggle (never itself sent to the game -
|
||||||
|
// see updateShiftVisual()); Ctrl/Alt are one-shot "armed" modifiers,
|
||||||
|
// sent to the game the moment they're pressed and released again as
|
||||||
|
// soon as the next key consumes them (see toggleModifier()/
|
||||||
|
// releaseArmedModifiers()) - there's no press-and-hold gesture with a
|
||||||
|
// laser pointer + trigger click, so armed-then-consumed stands in for
|
||||||
|
// holding the key down.
|
||||||
|
private boolean shiftActive = false;
|
||||||
|
private boolean ctrlArmed = false;
|
||||||
|
private boolean altArmed = false;
|
||||||
|
private Button shiftButton;
|
||||||
|
private Button ctrlButton;
|
||||||
|
private Button altButton;
|
||||||
|
|
||||||
|
// Every CharKey created by addRow(), so updateShiftVisual() can relabel
|
||||||
|
// all of them at once when Shift toggles. Assigned (not a field
|
||||||
|
// initializer) at the top of buildContent() - see OverlayPanel's
|
||||||
|
// constructor note on why a field a subclass's buildContent() depends
|
||||||
|
// on can't rely on normal field-initializer timing.
|
||||||
|
private List<CharKey> charKeys;
|
||||||
|
|
||||||
|
// A key whose printed character (and, while unmodified, whose game
|
||||||
|
// input) depends on the Shift layer - covers letters, digits, and
|
||||||
|
// punctuation uniformly (e.g. 'q'/'Q', '1'/'!', '-'/'_'). androidKeyCode
|
||||||
|
// is only used for the Ctrl/Alt-combo path (see handleCharKeyDown()) -
|
||||||
|
// the unmodified path goes through nativeSendPrintableCharDown()/Up()
|
||||||
|
// instead, which doesn't need an Android keycode at all.
|
||||||
|
private static final class CharKey {
|
||||||
|
final char base;
|
||||||
|
final char shifted;
|
||||||
|
final int androidKeyCode;
|
||||||
|
Button button;
|
||||||
|
|
||||||
|
CharKey(char base, char shifted, int androidKeyCode) {
|
||||||
|
this.base = base;
|
||||||
|
this.shifted = shifted;
|
||||||
|
this.androidKeyCode = androidKeyCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CharKey charKey(char base, char shifted, int androidKeyCode) {
|
||||||
|
return new CharKey(base, shifted, androidKeyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Space is a CharKey like any other (see addRow()), but a literal
|
||||||
|
// space character makes for an unreadable, blank-looking button - keep
|
||||||
|
// showing the word "Space" instead, same as before it became a CharKey.
|
||||||
|
private static String charKeyLabel(CharKey key, boolean shiftActive) {
|
||||||
|
if (key.base == ' ') {
|
||||||
|
return "Space";
|
||||||
|
}
|
||||||
|
return String.valueOf(shiftActive ? key.shifted : key.base);
|
||||||
|
}
|
||||||
|
|
||||||
|
private KeyboardOverlay(Activity activity) {
|
||||||
|
super(activity, WIDTH, HEIGHT);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected int contentAlpha() {
|
||||||
|
return PANEL_ALPHA;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected View buildContent() {
|
||||||
|
charKeys = new ArrayList<>();
|
||||||
|
currentLanguage = MenuOverlay.nativeGetLanguage();
|
||||||
|
|
||||||
|
LinearLayout panel = new LinearLayout(activity);
|
||||||
|
panel.setOrientation(LinearLayout.VERTICAL);
|
||||||
|
panel.setBackgroundColor(0xFF202020);
|
||||||
|
|
||||||
|
panel.addView(buildTitleBar(),
|
||||||
|
new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, TITLE_BAR_HEIGHT));
|
||||||
|
|
||||||
|
typedTextView = new TextView(activity);
|
||||||
|
typedTextView.setTextColor(0xFFFFFFFF);
|
||||||
|
typedTextView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 28);
|
||||||
|
typedTextView.setBackgroundColor(0xFF303030);
|
||||||
|
typedTextView.setPadding(20, 20, 20, 20);
|
||||||
|
panel.addView(typedTextView, new LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT, TYPED_TEXT_HEIGHT));
|
||||||
|
|
||||||
|
// Rows follow real keyboard geometry where it helps (Tab starting
|
||||||
|
// the QWERTY row, Enter ending the home row, Shift starting the
|
||||||
|
// bottom-letter row, a compact arrow cluster bottom-right) so the
|
||||||
|
// layout reads as a familiar keyboard rather than an arbitrary grid.
|
||||||
|
addRow(panel, "Esc", "F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10",
|
||||||
|
"F11", "F12", "Back");
|
||||||
|
addRow(panel,
|
||||||
|
charKey('`', '~', KeyEvent.KEYCODE_GRAVE),
|
||||||
|
charKey('1', '!', KeyEvent.KEYCODE_1),
|
||||||
|
charKey('2', '@', KeyEvent.KEYCODE_2),
|
||||||
|
charKey('3', '#', KeyEvent.KEYCODE_3),
|
||||||
|
charKey('4', '$', KeyEvent.KEYCODE_4),
|
||||||
|
charKey('5', '%', KeyEvent.KEYCODE_5),
|
||||||
|
charKey('6', '^', KeyEvent.KEYCODE_6),
|
||||||
|
charKey('7', '&', KeyEvent.KEYCODE_7),
|
||||||
|
charKey('8', '*', KeyEvent.KEYCODE_8),
|
||||||
|
charKey('9', '(', KeyEvent.KEYCODE_9),
|
||||||
|
charKey('0', ')', KeyEvent.KEYCODE_0),
|
||||||
|
charKey('-', '_', KeyEvent.KEYCODE_MINUS),
|
||||||
|
charKey('=', '+', KeyEvent.KEYCODE_EQUALS));
|
||||||
|
addRow(panel, "Tab",
|
||||||
|
charKey('q', 'Q', KeyEvent.KEYCODE_Q), charKey('w', 'W', KeyEvent.KEYCODE_W),
|
||||||
|
charKey('e', 'E', KeyEvent.KEYCODE_E), charKey('r', 'R', KeyEvent.KEYCODE_R),
|
||||||
|
charKey('t', 'T', KeyEvent.KEYCODE_T), charKey('y', 'Y', KeyEvent.KEYCODE_Y),
|
||||||
|
charKey('u', 'U', KeyEvent.KEYCODE_U), charKey('i', 'I', KeyEvent.KEYCODE_I),
|
||||||
|
charKey('o', 'O', KeyEvent.KEYCODE_O), charKey('p', 'P', KeyEvent.KEYCODE_P),
|
||||||
|
charKey('[', '{', KeyEvent.KEYCODE_LEFT_BRACKET),
|
||||||
|
charKey(']', '}', KeyEvent.KEYCODE_RIGHT_BRACKET),
|
||||||
|
charKey('\\', '|', KeyEvent.KEYCODE_BACKSLASH));
|
||||||
|
addRow(panel, "Ctrl",
|
||||||
|
charKey('a', 'A', KeyEvent.KEYCODE_A), charKey('s', 'S', KeyEvent.KEYCODE_S),
|
||||||
|
charKey('d', 'D', KeyEvent.KEYCODE_D), charKey('f', 'F', KeyEvent.KEYCODE_F),
|
||||||
|
charKey('g', 'G', KeyEvent.KEYCODE_G), charKey('h', 'H', KeyEvent.KEYCODE_H),
|
||||||
|
charKey('j', 'J', KeyEvent.KEYCODE_J), charKey('k', 'K', KeyEvent.KEYCODE_K),
|
||||||
|
charKey('l', 'L', KeyEvent.KEYCODE_L),
|
||||||
|
charKey(';', ':', KeyEvent.KEYCODE_SEMICOLON),
|
||||||
|
charKey('\'', '"', KeyEvent.KEYCODE_APOSTROPHE),
|
||||||
|
"Enter");
|
||||||
|
addRow(panel, "Shift",
|
||||||
|
charKey('z', 'Z', KeyEvent.KEYCODE_Z), charKey('x', 'X', KeyEvent.KEYCODE_X),
|
||||||
|
charKey('c', 'C', KeyEvent.KEYCODE_C), charKey('v', 'V', KeyEvent.KEYCODE_V),
|
||||||
|
charKey('b', 'B', KeyEvent.KEYCODE_B), charKey('n', 'N', KeyEvent.KEYCODE_N),
|
||||||
|
charKey('m', 'M', KeyEvent.KEYCODE_M),
|
||||||
|
charKey(',', '<', KeyEvent.KEYCODE_COMMA),
|
||||||
|
charKey('.', '>', KeyEvent.KEYCODE_PERIOD),
|
||||||
|
charKey('/', '?', KeyEvent.KEYCODE_SLASH),
|
||||||
|
"Up");
|
||||||
|
addRow(panel, "Alt", charKey(' ', ' ', KeyEvent.KEYCODE_SPACE), "Left", "Down", "Right");
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The title bar: a decorative "drag here" label (not itself
|
||||||
|
// interactive - see the class doc, dragging is handled entirely by
|
||||||
|
// xr_input.c before a hit ever reaches here) plus a real Close button,
|
||||||
|
// which is dispatched as an ordinary click like any other key. Its
|
||||||
|
// height and the Close button's width must stay in sync with
|
||||||
|
// xr_input.c's KEYBOARD_TITLE_BAR_V_FRACTION/
|
||||||
|
// KEYBOARD_CLOSE_BUTTON_U_FRACTION.
|
||||||
|
private LinearLayout buildTitleBar() {
|
||||||
|
LinearLayout bar = new LinearLayout(activity);
|
||||||
|
bar.setOrientation(LinearLayout.HORIZONTAL);
|
||||||
|
// Matches MenuOverlay's own header bar style.
|
||||||
|
bar.setBackgroundColor(0xFF000080);
|
||||||
|
|
||||||
|
titleLabel = new TextView(activity);
|
||||||
|
titleLabel.setText(Strings.get("keyboard_drag_title", currentLanguage));
|
||||||
|
titleLabel.setTextColor(0xFFFFFFFF);
|
||||||
|
titleLabel.setTextSize(TypedValue.COMPLEX_UNIT_SP, 20);
|
||||||
|
titleLabel.setGravity(Gravity.CENTER_VERTICAL);
|
||||||
|
titleLabel.setPadding(24, 0, 0, 0);
|
||||||
|
bar.addView(titleLabel, new LinearLayout.LayoutParams(
|
||||||
|
0, ViewGroup.LayoutParams.MATCH_PARENT, 1f));
|
||||||
|
|
||||||
|
closeButton = new Button(activity);
|
||||||
|
closeButton.setText(Strings.get("close", currentLanguage));
|
||||||
|
closeButton.setOnClickListener(v -> nativeRequestClose());
|
||||||
|
closeButton.setOnTouchListener(CLICK_ON_TOUCH_UP);
|
||||||
|
closeButton.setBackground(buttonBackground());
|
||||||
|
closeButton.setTextColor(buttonTextColors());
|
||||||
|
registerHoverable(closeButton);
|
||||||
|
bar.addView(closeButton,
|
||||||
|
new LinearLayout.LayoutParams(CLOSE_BUTTON_WIDTH, ViewGroup.LayoutParams.MATCH_PARENT));
|
||||||
|
|
||||||
|
return bar;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each row fills the panel's remaining height evenly (weight 1 on the
|
||||||
|
// row itself). Accepts a mix of String (a fixed-action control key,
|
||||||
|
// handled by handleKeyDown()/handleKeyUp()) and CharKey (a
|
||||||
|
// Shift-sensitive character key, handled by handleCharKeyDown()/
|
||||||
|
// handleCharKeyUp()) per item, so a row can match real keyboard
|
||||||
|
// geometry (e.g. "Tab" followed by a run of CharKeys).
|
||||||
|
// Within a row, CharKeys and most control keys share width equally
|
||||||
|
// except Space (wider, like a real keyboard) and Esc/Back/Enter/Tab/
|
||||||
|
// Shift/Ctrl/Alt (narrower, see keyWeight()).
|
||||||
|
private void addRow(LinearLayout panel, Object... items) {
|
||||||
|
LinearLayout row = new LinearLayout(activity);
|
||||||
|
row.setOrientation(LinearLayout.HORIZONTAL);
|
||||||
|
for (Object item : items) {
|
||||||
|
Button key = new Button(activity);
|
||||||
|
key.setBackground(buttonBackground());
|
||||||
|
key.setTextColor(buttonTextColors());
|
||||||
|
registerHoverable(key);
|
||||||
|
float weight;
|
||||||
|
if (item instanceof CharKey) {
|
||||||
|
CharKey charKey = (CharKey) item;
|
||||||
|
charKey.button = key;
|
||||||
|
key.setText(charKeyLabel(charKey, shiftActive));
|
||||||
|
key.setOnTouchListener(charKeyTouchListener(charKey));
|
||||||
|
charKeys.add(charKey);
|
||||||
|
// Space is a CharKey too (base == shifted == ' '), but keeps
|
||||||
|
// its traditional wide key like the other control keys do.
|
||||||
|
weight = (charKey.base == ' ') ? 4f : 1f;
|
||||||
|
} else {
|
||||||
|
String label = (String) item;
|
||||||
|
key.setText(label);
|
||||||
|
weight = keyWeight(label);
|
||||||
|
// Shift/Ctrl/Alt are local UI toggles with their own
|
||||||
|
// instantaneous press/release semantics (see
|
||||||
|
// toggleModifier()), not a value forwarded to the game for
|
||||||
|
// as long as the key is held - a plain click is enough.
|
||||||
|
// Every other control key reaches the game and needs real
|
||||||
|
// hold-duration semantics (see controlKeyTouchListener()).
|
||||||
|
if (label.equals("Shift") || label.equals("Ctrl") || label.equals("Alt")) {
|
||||||
|
key.setOnClickListener(v -> handleToggleKey(label));
|
||||||
|
key.setOnTouchListener(CLICK_ON_TOUCH_UP);
|
||||||
|
} else {
|
||||||
|
key.setOnTouchListener(controlKeyTouchListener(label));
|
||||||
|
}
|
||||||
|
if (label.equals("Shift")) {
|
||||||
|
shiftButton = key;
|
||||||
|
} else if (label.equals("Ctrl")) {
|
||||||
|
ctrlButton = key;
|
||||||
|
} else if (label.equals("Alt")) {
|
||||||
|
altButton = key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
row.addView(key, new LinearLayout.LayoutParams(
|
||||||
|
0, ViewGroup.LayoutParams.MATCH_PARENT, weight));
|
||||||
|
}
|
||||||
|
panel.addView(row, new LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drives a control key (everything controlKeyCode() maps, i.e. every
|
||||||
|
// key in addRow() except CharKeys and the Shift/Ctrl/Alt toggles) from
|
||||||
|
// its button's own touch lifecycle, so it reaches the game for exactly
|
||||||
|
// as long as the controller's trigger is held on it (see the class
|
||||||
|
// doc). ACTION_CANCEL (the ray straying off the button, or another
|
||||||
|
// overlay claiming it - see xr_input.c) releases the key the same as
|
||||||
|
// ACTION_UP, so a key can never be left stuck down.
|
||||||
|
private View.OnTouchListener controlKeyTouchListener(String label) {
|
||||||
|
return (v, event) -> {
|
||||||
|
switch (event.getAction()) {
|
||||||
|
case MotionEvent.ACTION_DOWN:
|
||||||
|
v.setPressed(true);
|
||||||
|
handleKeyDown(label);
|
||||||
|
break;
|
||||||
|
case MotionEvent.ACTION_UP:
|
||||||
|
v.setPressed(false);
|
||||||
|
handleKeyUp(label);
|
||||||
|
break;
|
||||||
|
case MotionEvent.ACTION_CANCEL:
|
||||||
|
v.setPressed(false);
|
||||||
|
handleKeyUp(label);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same as controlKeyTouchListener() above, for a CharKey.
|
||||||
|
private View.OnTouchListener charKeyTouchListener(CharKey key) {
|
||||||
|
return (v, event) -> {
|
||||||
|
switch (event.getAction()) {
|
||||||
|
case MotionEvent.ACTION_DOWN:
|
||||||
|
v.setPressed(true);
|
||||||
|
handleCharKeyDown(key);
|
||||||
|
break;
|
||||||
|
case MotionEvent.ACTION_UP:
|
||||||
|
v.setPressed(false);
|
||||||
|
handleCharKeyUp(key);
|
||||||
|
break;
|
||||||
|
case MotionEvent.ACTION_CANCEL:
|
||||||
|
v.setPressed(false);
|
||||||
|
handleCharKeyUp(key);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float keyWeight(String label) {
|
||||||
|
if (label.equals("Back") || label.equals("Esc") || label.equals("Enter")
|
||||||
|
|| label.equals("Tab") || label.equals("Shift") || label.equals("Ctrl")
|
||||||
|
|| label.equals("Alt")) {
|
||||||
|
return 1.5f;
|
||||||
|
}
|
||||||
|
return 1f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maps a control key's label to the Android keycode forwarded via
|
||||||
|
// SDLActivity.onNativeKeyDown()/onNativeKeyUp() - the same pair a
|
||||||
|
// physical Bluetooth keyboard's key events already drive (see
|
||||||
|
// SDLActivity.handleKeyEvent()). F-keys are contiguous in
|
||||||
|
// android.view.KeyEvent (KEYCODE_F1..KEYCODE_F12), so "F1".."F12" are
|
||||||
|
// computed rather than listed individually.
|
||||||
|
private static int controlKeyCode(String label) {
|
||||||
|
switch (label) {
|
||||||
|
case "Back": return KeyEvent.KEYCODE_DEL;
|
||||||
|
case "Esc": return KeyEvent.KEYCODE_ESCAPE;
|
||||||
|
case "Enter": return KeyEvent.KEYCODE_ENTER;
|
||||||
|
case "Tab": return KeyEvent.KEYCODE_TAB;
|
||||||
|
case "Up": return KeyEvent.KEYCODE_DPAD_UP;
|
||||||
|
case "Down": return KeyEvent.KEYCODE_DPAD_DOWN;
|
||||||
|
case "Left": return KeyEvent.KEYCODE_DPAD_LEFT;
|
||||||
|
case "Right": return KeyEvent.KEYCODE_DPAD_RIGHT;
|
||||||
|
default:
|
||||||
|
if (label.charAt(0) == 'F') {
|
||||||
|
int n = Integer.parseInt(label.substring(1));
|
||||||
|
return KeyEvent.KEYCODE_F1 + (n - 1);
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("no keycode for " + label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shift is a local layer toggle; Ctrl/Alt are one-shot armed modifiers
|
||||||
|
// (see toggleModifier()) - neither is forwarded to the game as a key of
|
||||||
|
// its own, so a plain click (rather than the hold-duration dispatch
|
||||||
|
// every other key below uses) is enough.
|
||||||
|
private void handleToggleKey(String label) {
|
||||||
|
if (label.equals("Shift")) {
|
||||||
|
shiftActive = !shiftActive;
|
||||||
|
updateShiftVisual();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toggleModifier(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything controlKeyCode() maps (Back/Esc/Enter/Tab/arrows/F1-F12)
|
||||||
|
// is non-printable, so it's forwarded as a real Android key event via
|
||||||
|
// that code - down on this key's own ACTION_DOWN, up on ACTION_UP/
|
||||||
|
// CANCEL (see controlKeyTouchListener()), the same down/up split a
|
||||||
|
// physical Bluetooth keyboard's own key events already carry. This all
|
||||||
|
// matters beyond just the visible typedTextView below: the engine's
|
||||||
|
// cutscene skip handler (cutsloop.c's cutscene_key_handler()) only
|
||||||
|
// reacts to Esc/Enter/Space, so those need to reach the game as actual
|
||||||
|
// game input, not just be echoed locally.
|
||||||
|
private void handleKeyDown(String label) {
|
||||||
|
int keyCode = controlKeyCode(label);
|
||||||
|
SDLActivity.onNativeKeyDown(keyCode);
|
||||||
|
|
||||||
|
if (label.equals("Back")) {
|
||||||
|
if (typedText.length() > 0) {
|
||||||
|
typedText.setLength(typedText.length() - 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
typedText.append('[').append(label).append(']');
|
||||||
|
}
|
||||||
|
typedTextView.setText(typedText.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleKeyUp(String label) {
|
||||||
|
SDLActivity.onNativeKeyUp(controlKeyCode(label));
|
||||||
|
releaseArmedModifiers();
|
||||||
|
}
|
||||||
|
|
||||||
|
// A CharKey's printed character depends only on the Shift layer
|
||||||
|
// (updateShiftVisual() keeps its button's label in sync). Which game
|
||||||
|
// input it produces depends on whether a modifier is armed: unmodified,
|
||||||
|
// it goes through nativeSendPrintableCharDown()/Up() below like a
|
||||||
|
// normal typed character; with Ctrl/Alt armed, it instead goes through
|
||||||
|
// the same onNativeKeyDown()/onNativeKeyUp() path handleKeyDown()/
|
||||||
|
// handleKeyUp() use for control keys, since that's the only path that
|
||||||
|
// carries ev.key.keysym.mod through to the engine (sdl_events.c's
|
||||||
|
// pump_events() reads Ctrl/Alt only off that path, never off
|
||||||
|
// SDL_TEXTINPUT).
|
||||||
|
private void handleCharKeyDown(CharKey key) {
|
||||||
|
char c = shiftActive ? key.shifted : key.base;
|
||||||
|
if (ctrlArmed || altArmed) {
|
||||||
|
SDLActivity.onNativeKeyDown(key.androidKeyCode);
|
||||||
|
} else {
|
||||||
|
nativeSendPrintableCharDown(c);
|
||||||
|
}
|
||||||
|
typedText.append(c);
|
||||||
|
typedTextView.setText(typedText.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleCharKeyUp(CharKey key) {
|
||||||
|
char c = shiftActive ? key.shifted : key.base;
|
||||||
|
if (ctrlArmed || altArmed) {
|
||||||
|
SDLActivity.onNativeKeyUp(key.androidKeyCode);
|
||||||
|
releaseArmedModifiers();
|
||||||
|
} else {
|
||||||
|
nativeSendPrintableCharUp(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl/Alt are "armed" rather than held: pressing one immediately sends
|
||||||
|
// its keydown (so SDL's own modifier tracking picks it up for whatever
|
||||||
|
// key comes next - see handleCharKeyDown()/handleKeyDown()) and
|
||||||
|
// brackets its label for feedback; pressing the same key again before
|
||||||
|
// it's been used cancels it (sends the matching keyup, un-brackets).
|
||||||
|
// The normal case - actually being consumed by the next key press - is
|
||||||
|
// handled by releaseArmedModifiers() below, not here.
|
||||||
|
private void toggleModifier(String label) {
|
||||||
|
boolean ctrl = label.equals("Ctrl");
|
||||||
|
Button button = ctrl ? ctrlButton : altButton;
|
||||||
|
int keyCode = ctrl ? KeyEvent.KEYCODE_CTRL_LEFT : KeyEvent.KEYCODE_ALT_LEFT;
|
||||||
|
boolean nowArmed = ctrl ? !ctrlArmed : !altArmed;
|
||||||
|
if (ctrl) {
|
||||||
|
ctrlArmed = nowArmed;
|
||||||
|
} else {
|
||||||
|
altArmed = nowArmed;
|
||||||
|
}
|
||||||
|
if (nowArmed) {
|
||||||
|
SDLActivity.onNativeKeyDown(keyCode);
|
||||||
|
} else {
|
||||||
|
SDLActivity.onNativeKeyUp(keyCode);
|
||||||
|
}
|
||||||
|
button.setText(nowArmed ? "[" + label + "]" : label);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called after any key actually reaches the game (a control key's
|
||||||
|
// handleKeyUp(), or a CharKey's handleCharKeyUp()) so an armed Ctrl/Alt
|
||||||
|
// only ever applies to the very next key, then releases - matching a
|
||||||
|
// real Ctrl/Alt+key combo's keyup once the combo is done.
|
||||||
|
private void releaseArmedModifiers() {
|
||||||
|
if (ctrlArmed) {
|
||||||
|
SDLActivity.onNativeKeyUp(KeyEvent.KEYCODE_CTRL_LEFT);
|
||||||
|
ctrlArmed = false;
|
||||||
|
ctrlButton.setText("Ctrl");
|
||||||
|
}
|
||||||
|
if (altArmed) {
|
||||||
|
SDLActivity.onNativeKeyUp(KeyEvent.KEYCODE_ALT_LEFT);
|
||||||
|
altArmed = false;
|
||||||
|
altButton.setText("Alt");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shift never itself reaches the game (see class doc) - it only flips
|
||||||
|
// which character each CharKey shows/sends, including its own label.
|
||||||
|
// Note this means a Ctrl+Shift+key chord won't carry Shift to the
|
||||||
|
// engine (Ctrl/Alt are real forwarded modifier keys, Shift here isn't) -
|
||||||
|
// not worth solving unless it actually comes up.
|
||||||
|
private void updateShiftVisual() {
|
||||||
|
shiftButton.setText(shiftActive ? "[Shift]" : "Shift");
|
||||||
|
for (CharKey key : charKeys) {
|
||||||
|
key.button.setText(charKeyLabel(key, shiftActive));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Synthesizes the SDL_TEXTINPUT/SDL_KEYUP pair pump_events()
|
||||||
|
// (sdl_events.c) requires for printable characters (see
|
||||||
|
// handleCharKeyDown()/handleCharKeyUp() above), by building and
|
||||||
|
// pushing them directly in native code rather than through Android's
|
||||||
|
// IME (there's no real IME session behind this off-screen,
|
||||||
|
// never-attached grid for that plumbing to hook into).
|
||||||
|
private static native void nativeSendPrintableCharDown(char c);
|
||||||
|
|
||||||
|
private static native void nativeSendPrintableCharUp(char c);
|
||||||
|
|
||||||
|
// Hides this overlay's quad (xr_overlay_set_visible(..., false)) - the
|
||||||
|
// only way to do so, since the controller's menu button doesn't affect
|
||||||
|
// the keyboard (see this class's doc comment and xr_input.c).
|
||||||
|
private static native void nativeRequestClose();
|
||||||
|
|
||||||
|
// Called from MenuOverlay's own periodic header-refresh timer when it
|
||||||
|
// detects the game's language preference changed mid-session (via its
|
||||||
|
// own options menu) - re-labels this panel's title bar chrome to match.
|
||||||
|
// A plain Java call (both classes are in this same package), not JNI -
|
||||||
|
// runOnUiThread() here is defensive/self-documenting more than
|
||||||
|
// strictly needed today, since MenuOverlay's own timer already runs on
|
||||||
|
// the UI thread itself (Activity.runOnUiThread() is a no-op wrapper
|
||||||
|
// when already on that thread).
|
||||||
|
public static void applyTranslations(final int language) {
|
||||||
|
if (instance == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
instance.activity.runOnUiThread(() -> {
|
||||||
|
instance.currentLanguage = language;
|
||||||
|
instance.titleLabel.setText(Strings.get("keyboard_drag_title", language));
|
||||||
|
instance.closeButton.setText(Strings.get("close", language));
|
||||||
|
instance.redrawContent();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called from xr_overlay.c's xr_overlay_init_jni(), on the render
|
||||||
|
// thread, right after it resolves this class via the activity's own
|
||||||
|
// ClassLoader (plain FindClass() can't see app classes from a thread
|
||||||
|
// that was attached to the JVM rather than spawned from Java).
|
||||||
|
public static void nativeInit(Activity activity) {
|
||||||
|
if (instance == null) {
|
||||||
|
instance = new KeyboardOverlay(activity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] nativeTakePixelsIfDirty() {
|
||||||
|
return instance == null ? null : instance.takePixelsIfDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void nativeDispatchTouch(final float u, final float v, final boolean down) {
|
||||||
|
if (instance != null) {
|
||||||
|
instance.dispatchTouch(u, v, down);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void nativeUpdateCursor(final float u, final float v, final boolean visible) {
|
||||||
|
if (instance != null) {
|
||||||
|
instance.dispatchUpdateCursor(u, v, visible);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
package de.ladkau.questshock;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.res.ColorStateList;
|
||||||
|
import android.graphics.Bitmap;
|
||||||
|
import android.graphics.Canvas;
|
||||||
|
import android.graphics.Paint;
|
||||||
|
import android.graphics.Rect;
|
||||||
|
import android.graphics.drawable.ColorDrawable;
|
||||||
|
import android.graphics.drawable.Drawable;
|
||||||
|
import android.graphics.drawable.StateListDrawable;
|
||||||
|
import android.os.SystemClock;
|
||||||
|
import android.view.MotionEvent;
|
||||||
|
import android.view.View;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared machinery for an off-screen, never-attached-to-a-window View tree
|
||||||
|
* rendered into its own OpenXR quad (see android/app/src/main/cpp/
|
||||||
|
* xr_overlay.c, which owns the quad's swapchain and drives whichever
|
||||||
|
* concrete subclass via JNI - construction, touch input, and pixel
|
||||||
|
* readback). Rendering to a Bitmap and dispatching synthetic MotionEvents
|
||||||
|
* into an unattached hierarchy both work the same way they would for an
|
||||||
|
* attached View - draw(Canvas)/dispatchTouchEvent() don't require a
|
||||||
|
* ViewRootImpl/window, just a measured+laid-out tree.
|
||||||
|
*
|
||||||
|
* Subclasses (MenuOverlay, KeyboardOverlay) provide their own content via
|
||||||
|
* buildContent() and, if they want translucency, contentAlpha() - and, since
|
||||||
|
* Java has no static virtual dispatch and each overlay needs its own
|
||||||
|
* singleton, each subclass still declares its own thin nativeInit()/
|
||||||
|
* nativeTakePixelsIfDirty()/nativeDispatchTouch()/nativeUpdateCursor()
|
||||||
|
* static methods (matching what xr_overlay.c's JNI glue resolves by class
|
||||||
|
* name) that just delegate into the instance methods here
|
||||||
|
* (takePixelsIfDirty()/dispatchTouch()/dispatchUpdateCursor()).
|
||||||
|
*/
|
||||||
|
abstract class OverlayPanel {
|
||||||
|
private static final float CURSOR_RADIUS = 10f;
|
||||||
|
|
||||||
|
// Button's own click handling (View.onTouchEvent()'s ACTION_UP case)
|
||||||
|
// calls View.post(mPerformClick) rather than invoking performClick()
|
||||||
|
// directly; post() queues the runnable to run once the view is attached
|
||||||
|
// to a window and returns true immediately even when unattached, so on
|
||||||
|
// this permanently-unattached tree that queued click silently never
|
||||||
|
// fires - dispatchTouchEvent() still delivers the down/up events
|
||||||
|
// correctly, only the click callback is swallowed. Registering this as
|
||||||
|
// each button's OnTouchListener bypasses that path entirely: returning
|
||||||
|
// true here skips View's internal onTouchEvent() (see
|
||||||
|
// ViewGroup/View#dispatchTouchEvent), so performClick() (which still
|
||||||
|
// runs any OnClickListener set via setOnClickListener()) is called
|
||||||
|
// directly instead.
|
||||||
|
protected static final View.OnTouchListener CLICK_ON_TOUCH_UP = (v, event) -> {
|
||||||
|
switch (event.getAction()) {
|
||||||
|
case MotionEvent.ACTION_DOWN:
|
||||||
|
v.setPressed(true);
|
||||||
|
break;
|
||||||
|
case MotionEvent.ACTION_UP:
|
||||||
|
v.setPressed(false);
|
||||||
|
v.performClick();
|
||||||
|
break;
|
||||||
|
case MotionEvent.ACTION_CANCEL:
|
||||||
|
v.setPressed(false);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A plain, instantly-drawn StateListDrawable rather than the platform
|
||||||
|
// default Button background (a Material ripple): ripples animate via
|
||||||
|
// continuous invalidation callbacks tied to a real attached window's
|
||||||
|
// vsync, which never fires here (this tree is never attached to a
|
||||||
|
// window) - so setPressed()/setHovered() state changes would otherwise
|
||||||
|
// produce no visible change at all, independent of this project's own
|
||||||
|
// touch handling (which already calls setPressed() correctly - see
|
||||||
|
// CLICK_ON_TOUCH_UP above). Every button this project creates
|
||||||
|
// (MenuOverlay, KeyboardOverlay) should use this (or
|
||||||
|
// MenuOverlay.selectableButtonBackground(), which adds a persistent
|
||||||
|
// "selected" state on top of the same normal/hovered/pressed colors) -
|
||||||
|
// paired with buttonTextColors() below, since the normal/hovered state
|
||||||
|
// is light enough to need dark text while pressed/selected need white.
|
||||||
|
protected static Drawable buttonBackground() {
|
||||||
|
StateListDrawable drawable = new StateListDrawable();
|
||||||
|
drawable.addState(new int[]{android.R.attr.state_pressed}, new ColorDrawable(PRESSED_COLOR));
|
||||||
|
drawable.addState(new int[]{android.R.attr.state_hovered}, new ColorDrawable(HOVERED_COLOR));
|
||||||
|
drawable.addState(new int[]{}, new ColorDrawable(NORMAL_COLOR));
|
||||||
|
return drawable;
|
||||||
|
}
|
||||||
|
|
||||||
|
// See buttonBackground() above - light gray (normal/hovered) needs dark
|
||||||
|
// text; the accent colors (pressed, and MenuOverlay's own "selected"
|
||||||
|
// state) need white. Every button should get both this and
|
||||||
|
// buttonBackground()/selectableButtonBackground() together.
|
||||||
|
protected static ColorStateList buttonTextColors() {
|
||||||
|
return new ColorStateList(
|
||||||
|
new int[][]{
|
||||||
|
new int[]{android.R.attr.state_pressed},
|
||||||
|
new int[]{android.R.attr.state_selected},
|
||||||
|
new int[]{},
|
||||||
|
},
|
||||||
|
new int[]{0xFFFFFFFF, 0xFFFFFFFF, 0xFF202020});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static final int NORMAL_COLOR = 0xFFC8C8C8;
|
||||||
|
protected static final int HOVERED_COLOR = 0xFFAEC2E8;
|
||||||
|
protected static final int PRESSED_COLOR = 0xFF3355AA;
|
||||||
|
|
||||||
|
protected final Activity activity;
|
||||||
|
private final int width;
|
||||||
|
private final int height;
|
||||||
|
private final View root;
|
||||||
|
|
||||||
|
// Views registered via registerHoverable() (every button, from both
|
||||||
|
// buildContent() and any later-added content) - hit-tested against the
|
||||||
|
// cursor position on every updateCursor() call so aiming at (not
|
||||||
|
// clicking) one visibly highlights it via android.R.attr.state_hovered
|
||||||
|
// (a real View state since API 14, paired with buttonBackground()
|
||||||
|
// above).
|
||||||
|
private final List<View> hoverableViews = new ArrayList<>();
|
||||||
|
private View hoveredView = null;
|
||||||
|
|
||||||
|
// contentBitmap holds just the panel's own rendered content (no cursor),
|
||||||
|
// redrawn only when that content actually changes (redrawContent(), via
|
||||||
|
// handleTouch() or a subclass's own key handling) via root.draw() - a
|
||||||
|
// full off-screen View-tree traversal that gets noticeably more
|
||||||
|
// expensive on a busier content tree. bitmap is what's actually
|
||||||
|
// published to native (see takePixelsIfDirty()): compositeAndPublish()
|
||||||
|
// cheaply blits contentBitmap plus the cursor circle into it. Splitting
|
||||||
|
// these two apart matters because updateCursor() below fires every
|
||||||
|
// single frame (~90Hz) while aiming at the panel - if it called the
|
||||||
|
// expensive root.draw() path every time (as an earlier version did), a
|
||||||
|
// busy content tree made each redraw slow enough that runOnUiThread()
|
||||||
|
// posts piled up faster than the UI thread could drain them, so the
|
||||||
|
// cursor visibly lagged behind the controller's actual aim instead of
|
||||||
|
// tracking it.
|
||||||
|
private final Bitmap contentBitmap;
|
||||||
|
private final Canvas contentCanvas;
|
||||||
|
private final Bitmap bitmap;
|
||||||
|
private final Canvas canvas;
|
||||||
|
private final Paint cursorPaint;
|
||||||
|
// Draws contentBitmap at contentAlpha() - see compositeAndPublish().
|
||||||
|
private final Paint contentAlphaPaint;
|
||||||
|
|
||||||
|
private final Object pixelLock = new Object();
|
||||||
|
private byte[] pendingPixels;
|
||||||
|
|
||||||
|
// Only ever touched on the UI thread (both dispatchUpdateCursor() and
|
||||||
|
// compositeAndPublish() run/are posted there) - no lock needed, unlike
|
||||||
|
// pendingPixels above.
|
||||||
|
private boolean cursorVisible = false;
|
||||||
|
private float cursorX = 0f;
|
||||||
|
private float cursorY = 0f;
|
||||||
|
|
||||||
|
protected OverlayPanel(Activity activity, int width, int height) {
|
||||||
|
this.activity = activity;
|
||||||
|
this.width = width;
|
||||||
|
this.height = height;
|
||||||
|
|
||||||
|
root = buildContent();
|
||||||
|
int widthSpec = View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY);
|
||||||
|
int heightSpec = View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY);
|
||||||
|
root.measure(widthSpec, heightSpec);
|
||||||
|
root.layout(0, 0, width, height);
|
||||||
|
|
||||||
|
contentBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
|
||||||
|
contentCanvas = new Canvas(contentBitmap);
|
||||||
|
bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
|
||||||
|
canvas = new Canvas(bitmap);
|
||||||
|
|
||||||
|
cursorPaint = new Paint();
|
||||||
|
cursorPaint.setColor(0xFFFFFFFF);
|
||||||
|
cursorPaint.setAntiAlias(true);
|
||||||
|
|
||||||
|
// contentAlpha() must not depend on any subclass instance state -
|
||||||
|
// it's called here, from the superclass constructor, before the
|
||||||
|
// subclass's own field initializers/constructor body have run (the
|
||||||
|
// usual Java construction order: this class's fields/constructor
|
||||||
|
// first, then the subclass's). A static constant is the only thing
|
||||||
|
// that's safe to return.
|
||||||
|
contentAlphaPaint = new Paint();
|
||||||
|
contentAlphaPaint.setAlpha(contentAlpha());
|
||||||
|
|
||||||
|
redrawContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds this overlay's content View tree - called once, from this
|
||||||
|
// class's own constructor (see the note on contentAlpha() above: don't
|
||||||
|
// rely on subclass instance fields being initialized yet here; if a
|
||||||
|
// subclass needs its own mutable state available while building its
|
||||||
|
// content, e.g. a list of buttons to later relabel, initialize that
|
||||||
|
// field's value at the top of this method instead of via a field
|
||||||
|
// initializer).
|
||||||
|
protected abstract View buildContent();
|
||||||
|
|
||||||
|
// 0-255; defaults to fully opaque. Override for translucency (see the
|
||||||
|
// constructor's note on what's safe to depend on here).
|
||||||
|
protected int contentAlpha() {
|
||||||
|
return 255;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Polled once per frame while this overlay's quad is visible (see
|
||||||
|
// xr_overlay_render_and_build_layer()) - returns the current pixels
|
||||||
|
// only once per redraw (null otherwise), so the native side knows when
|
||||||
|
// it can skip re-uploading a texture that hasn't actually changed.
|
||||||
|
protected final byte[] takePixelsIfDirty() {
|
||||||
|
synchronized (pixelLock) {
|
||||||
|
byte[] pixels = pendingPixels;
|
||||||
|
pendingPixels = null;
|
||||||
|
return pixels;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// u/v are this quad's own hit-test coordinates (0..1, top-left origin)
|
||||||
|
// - computed by xr_input.c's ray/quad intersection against xr_overlay.c's
|
||||||
|
// reported quad geometry, forwarded here as a synthetic tap. Runs on the
|
||||||
|
// UI thread since the View/Bitmap/Canvas objects here are otherwise only
|
||||||
|
// ever touched from there.
|
||||||
|
protected final void dispatchTouch(final float u, final float v, final boolean down) {
|
||||||
|
activity.runOnUiThread(() -> handleTouch(u, v, down));
|
||||||
|
}
|
||||||
|
|
||||||
|
// u/v are the same hit-test coordinates dispatchTouch() uses, but
|
||||||
|
// polled once per frame regardless of click state rather than only on
|
||||||
|
// click edges - lets the cursor track the aim ray continuously instead
|
||||||
|
// of only jumping when a trigger is pressed. visible=false (no hand's
|
||||||
|
// ray currently on the quad) hides it.
|
||||||
|
protected final void dispatchUpdateCursor(final float u, final float v,
|
||||||
|
final boolean visible) {
|
||||||
|
activity.runOnUiThread(() -> updateCursor(u, v, visible));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateCursor(float u, float v, boolean visible) {
|
||||||
|
float x = u * width;
|
||||||
|
float y = v * height;
|
||||||
|
if (visible == cursorVisible && x == cursorX && y == cursorY) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cursorVisible = visible;
|
||||||
|
cursorX = x;
|
||||||
|
cursorY = y;
|
||||||
|
if (updateHoverState(x, y, visible)) {
|
||||||
|
// redrawContent() (called inside updateHoverState()) already
|
||||||
|
// composited and published this frame's pixels - nothing more
|
||||||
|
// to do.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
compositeAndPublish();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registers a button (or any View) to be hit-tested against the cursor
|
||||||
|
// position - called once per button, alongside buttonBackground()/
|
||||||
|
// setOnTouchListener(), from buildContent() (or any later content
|
||||||
|
// rebuild).
|
||||||
|
protected final void registerHoverable(View view) {
|
||||||
|
hoverableViews.add(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clears whatever's currently hovered without waiting for the cursor to
|
||||||
|
// actually move off of it - call after hiding/showing content (e.g. a
|
||||||
|
// tab switch), since a view that was hovered right before becoming
|
||||||
|
// GONE would otherwise still be internally tracked as hovered (and
|
||||||
|
// wrongly show that way again) if it's later made VISIBLE again before
|
||||||
|
// the cursor position next changes.
|
||||||
|
protected final void resetHover() {
|
||||||
|
if (hoveredView != null) {
|
||||||
|
hoveredView.setHovered(false);
|
||||||
|
hoveredView = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Absolute bounds of view within this panel's own coordinate space
|
||||||
|
// (i.e. relative to root, which is always laid out at (0,0,width,
|
||||||
|
// height) - see the constructor) - View's own getLeft()/getTop() are
|
||||||
|
// parent-relative, so this walks up the parent chain summing them,
|
||||||
|
// additionally subtracting each ancestor's own getScrollX()/getScrollY()
|
||||||
|
// (0 for a plain ViewGroup, non-zero for e.g. a ScrollView - see
|
||||||
|
// MenuOverlay's Controller Options tab) so a view inside a scrolled
|
||||||
|
// container reports its actual on-screen position, not its laid-out one.
|
||||||
|
// Assumes view is actually part of this panel's tree (every registered
|
||||||
|
// view always is, since registerHoverable() is only ever called from
|
||||||
|
// buildContent()).
|
||||||
|
private Rect absoluteBounds(View view) {
|
||||||
|
int left = 0;
|
||||||
|
int top = 0;
|
||||||
|
View current = view;
|
||||||
|
while (true) {
|
||||||
|
left += current.getLeft();
|
||||||
|
top += current.getTop();
|
||||||
|
if (current == root || !(current.getParent() instanceof View)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
View parent = (View) current.getParent();
|
||||||
|
left -= parent.getScrollX();
|
||||||
|
top -= parent.getScrollY();
|
||||||
|
current = parent;
|
||||||
|
}
|
||||||
|
return new Rect(left, top, left + view.getWidth(), top + view.getHeight());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns true if the hovered view changed (and so redrawContent() -
|
||||||
|
// which always publishes too - already ran); false if there's nothing
|
||||||
|
// more for updateCursor() to do beyond its own compositeAndPublish().
|
||||||
|
private boolean updateHoverState(float x, float y, boolean visible) {
|
||||||
|
View newHovered = null;
|
||||||
|
if (visible) {
|
||||||
|
for (View candidate : hoverableViews) {
|
||||||
|
if (candidate.getVisibility() != View.VISIBLE) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (absoluteBounds(candidate).contains((int) x, (int) y)) {
|
||||||
|
newHovered = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (newHovered == hoveredView) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (hoveredView != null) {
|
||||||
|
hoveredView.setHovered(false);
|
||||||
|
}
|
||||||
|
if (newHovered != null) {
|
||||||
|
newHovered.setHovered(true);
|
||||||
|
}
|
||||||
|
hoveredView = newHovered;
|
||||||
|
redrawContent();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleTouch(float u, float v, boolean down) {
|
||||||
|
float x = u * width;
|
||||||
|
float y = v * height;
|
||||||
|
long time = SystemClock.uptimeMillis();
|
||||||
|
MotionEvent event = MotionEvent.obtain(
|
||||||
|
time, time, down ? MotionEvent.ACTION_DOWN : MotionEvent.ACTION_UP, x, y, 0);
|
||||||
|
try {
|
||||||
|
root.dispatchTouchEvent(event);
|
||||||
|
} finally {
|
||||||
|
event.recycle();
|
||||||
|
}
|
||||||
|
redrawContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-runs the full off-screen View-tree draw (expensive - see the
|
||||||
|
// contentBitmap field comment above) - call only when the panel's
|
||||||
|
// actual content changed, not for the cursor-only updates
|
||||||
|
// compositeAndPublish() below handles on its own.
|
||||||
|
protected final void redrawContent() {
|
||||||
|
contentCanvas.drawColor(0xFF202020);
|
||||||
|
root.draw(contentCanvas);
|
||||||
|
compositeAndPublish();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cheap per-frame path: blits the last-rendered contentBitmap (no View
|
||||||
|
// traversal) plus the cursor circle into bitmap and publishes it.
|
||||||
|
// contentBitmap itself is fully opaque (redrawContent() draws an opaque
|
||||||
|
// background first); contentAlpha() translucency is applied right here
|
||||||
|
// instead, via contentAlphaPaint - which needs bitmap cleared to fully
|
||||||
|
// transparent first, since otherwise each frame's alpha-blended draw
|
||||||
|
// would blend against whatever was left over in bitmap from the
|
||||||
|
// previous frame rather than against nothing, compounding into a
|
||||||
|
// ghosting trail across frames.
|
||||||
|
private void compositeAndPublish() {
|
||||||
|
bitmap.eraseColor(0);
|
||||||
|
canvas.drawBitmap(contentBitmap, 0, 0, contentAlphaPaint);
|
||||||
|
if (cursorVisible) {
|
||||||
|
canvas.drawCircle(cursorX, cursorY, CURSOR_RADIUS, cursorPaint);
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] pixels = new byte[width * height * 4];
|
||||||
|
// ARGB_8888's actual in-memory byte order is R,G,B,A - matches
|
||||||
|
// GL_RGBA/GL_UNSIGNED_BYTE on the native side with no swizzling.
|
||||||
|
bitmap.copyPixelsToBuffer(ByteBuffer.wrap(pixels));
|
||||||
|
synchronized (pixelLock) {
|
||||||
|
pendingPixels = pixels;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,9 @@ 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.os.Handler;
|
|
||||||
import android.os.Looper;
|
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
import android.view.SurfaceHolder;
|
import android.view.SurfaceHolder;
|
||||||
import androidx.core.app.ActivityCompat;
|
import androidx.core.app.ActivityCompat;
|
||||||
@@ -20,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
|
||||||
@@ -41,7 +41,8 @@ import org.libsdl.app.SDLSurface;
|
|||||||
public class QuestShockActivity extends SDLActivity {
|
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;
|
||||||
private static final String GAME_DIR = "/sdcard/questshock";
|
// Package-visible: MenuOverlay reads controller_profiles/ from here.
|
||||||
|
static final String GAME_DIR = "/sdcard/QuestShock";
|
||||||
|
|
||||||
// Loaded here (rather than waiting for SDLActivity's own
|
// 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())
|
||||||
@@ -59,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[] {
|
||||||
@@ -145,11 +149,25 @@ public class QuestShockActivity extends SDLActivity {
|
|||||||
// is already there, assume a previous run already extracted everything.
|
// is already there, assume a previous run already extracted everything.
|
||||||
File marker = new File(gameDir, "GET_ASSETS_QUEST.txt");
|
File marker = new File(gameDir, "GET_ASSETS_QUEST.txt");
|
||||||
if (!marker.exists()) {
|
if (!marker.exists()) {
|
||||||
copyAssetDir("shaders", new File(gameDir, "shaders"));
|
copyAssetDir("shaders", new File(gameDir, "shaders"), false);
|
||||||
copyAssetFile("res/soundfont.sf2", new File(gameDir, "res/soundfont.sf2"));
|
copyAssetFile("res/soundfont.sf2", new File(gameDir, "res/soundfont.sf2"), false);
|
||||||
copyAssetFile("GET_ASSETS_QUEST.txt", marker);
|
copyAssetFile("GET_ASSETS_QUEST.txt", marker, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Refreshed on every launch (unlike the marker-gated block above) so
|
||||||
|
// it always matches the installed build - qs_config.c reads this as
|
||||||
|
// the initial config only when questshock.config.json doesn't exist
|
||||||
|
// yet, never overwriting a user's own saved config.
|
||||||
|
copyAssetFile("questshock.config.default.json",
|
||||||
|
new File(gameDir, "questshock.config.default.json"), true);
|
||||||
|
|
||||||
|
// Refreshed on every launch. MenuOverlay's Controller Options tab
|
||||||
|
// matches the active OpenXR interaction profile against these files'
|
||||||
|
// "interaction_profile" and shows the paired image (see CONFIG.md).
|
||||||
|
// Only the bundled filenames are overwritten, so profiles a user adds
|
||||||
|
// to this folder are kept.
|
||||||
|
copyAssetDir("controller_profiles", new File(gameDir, "controller_profiles"), true);
|
||||||
|
|
||||||
// Shockolate's engine/ is never patched to check this itself (see the
|
// Shockolate's engine/ is never patched to check this itself (see the
|
||||||
// class doc above) - it just does plain fopen("res/data/...", ...)
|
// class doc above) - it just does plain fopen("res/data/...", ...)
|
||||||
// and, on a fresh install with no game data copied in yet, that
|
// and, on a fresh install with no game data copied in yet, that
|
||||||
@@ -174,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;
|
||||||
@@ -206,15 +244,15 @@ 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()
|
||||||
.show();
|
.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void copyAssetFile(String assetPath, File dest) {
|
private void copyAssetFile(String assetPath, File dest, boolean overwrite) {
|
||||||
if (dest.exists()) {
|
if (dest.exists() && !overwrite) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
File parent = dest.getParentFile();
|
File parent = dest.getParentFile();
|
||||||
@@ -234,7 +272,7 @@ public class QuestShockActivity extends SDLActivity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void copyAssetDir(String assetDir, File destDir) {
|
private void copyAssetDir(String assetDir, File destDir, boolean overwrite) {
|
||||||
AssetManager assets = getAssets();
|
AssetManager assets = getAssets();
|
||||||
String[] entries;
|
String[] entries;
|
||||||
try {
|
try {
|
||||||
@@ -248,7 +286,7 @@ public class QuestShockActivity extends SDLActivity {
|
|||||||
}
|
}
|
||||||
destDir.mkdirs();
|
destDir.mkdirs();
|
||||||
for (String entry : entries) {
|
for (String entry : entries) {
|
||||||
copyAssetFile(assetDir + "/" + entry, new File(destDir, entry));
|
copyAssetFile(assetDir + "/" + entry, new File(destDir, entry), overwrite);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
package de.ladkau.questshock;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort (not professionally reviewed) translations for this app's own
|
||||||
|
* UI chrome text - the menu/keyboard panels this project draws itself, not
|
||||||
|
* the game's own resource-string text (which already has its own separate
|
||||||
|
* English/French/German data files). Selected by gShockPrefs.goLanguage (0
|
||||||
|
* English/1 French/2 German, Prefs.h) via MenuOverlay's native language
|
||||||
|
* query (qs_config_current_language()) - see MenuOverlay's periodic header-
|
||||||
|
* refresh timer, which also pushes it to KeyboardOverlay.
|
||||||
|
*
|
||||||
|
* Deliberately excludes the keyboard's own alphanumeric key labels (a-z,
|
||||||
|
* Tab, Space, Esc, ...) - those represent physical key positions/characters
|
||||||
|
* rather than translatable UI copy - and the restart-confirmation dialog's
|
||||||
|
* message text, which is built in native code (qs_config.c's
|
||||||
|
* qs_show_restart_dialog()) with an interpolated option-name list and is
|
||||||
|
* not currently reachable in practice (see CONFIG.md - neither Video
|
||||||
|
* Options option is flagged restart-required today).
|
||||||
|
*/
|
||||||
|
final class Strings {
|
||||||
|
static final int ENGLISH = 0;
|
||||||
|
static final int FRENCH = 1;
|
||||||
|
static final int GERMAN = 2;
|
||||||
|
|
||||||
|
private static final Map<String, String[]> TABLE = new HashMap<>();
|
||||||
|
|
||||||
|
private static void put(String key, String en, String fr, String de) {
|
||||||
|
TABLE.put(key, new String[]{en, fr, de});
|
||||||
|
}
|
||||||
|
|
||||||
|
static {
|
||||||
|
put("keyboard", "Keyboard", "Clavier", "Tastatur");
|
||||||
|
put("save_configuration", "Save Configuration", "Enregistrer la configuration",
|
||||||
|
"Konfiguration speichern");
|
||||||
|
put("load_configuration", "Load Configuration", "Charger la configuration",
|
||||||
|
"Konfiguration laden");
|
||||||
|
put("reset_position", "Reset Position", "Réinitialiser la position",
|
||||||
|
"Position zurücksetzen");
|
||||||
|
put("main_menu", "Main Menu", "Menu principal", "Hauptmenü");
|
||||||
|
put("video_options", "Video Options", "Options vidéo", "Videooptionen");
|
||||||
|
put("dismiss", "Dismiss", "Fermer", "Schließen");
|
||||||
|
put("restart_now", "Restart Now", "Redémarrer maintenant", "Jetzt neu starten");
|
||||||
|
put("renderer", "Renderer", "Moteur de rendu", "Renderer");
|
||||||
|
put("resolution", "Resolution", "Résolution", "Auflösung");
|
||||||
|
put("software", "Software", "Logiciel", "Software");
|
||||||
|
put("language", "Language", "Langue", "Sprache");
|
||||||
|
put("english", "English", "Anglais", "Englisch");
|
||||||
|
put("french", "French", "Français", "Französisch");
|
||||||
|
put("german", "German", "Allemand", "Deutsch");
|
||||||
|
put("initial_screen", "Initial Screen", "Écran initial", "Startbildschirm");
|
||||||
|
put("game_screen", "Game Screen", "Écran de jeu", "Spielbildschirm");
|
||||||
|
put("width", "Width", "Largeur", "Breite");
|
||||||
|
put("distance", "Distance", "Distance", "Abstand");
|
||||||
|
// "OpenGL" is a proper/brand name - not translated, same in every
|
||||||
|
// language, so it has no entry here; call sites just use it as a
|
||||||
|
// literal.
|
||||||
|
put("keyboard_drag_title", "Keyboard - drag here to move",
|
||||||
|
"Clavier - faites glisser ici pour déplacer",
|
||||||
|
"Tastatur - hier ziehen zum Verschieben");
|
||||||
|
put("close", "Close", "Fermer", "Schließen");
|
||||||
|
|
||||||
|
// Controller Options tab: action row labels, keyed by xr_input.c's
|
||||||
|
// action names (see MenuOverlay.ACTION_KEYS).
|
||||||
|
put("controller_options", "Controller Options", "Options de manette", "Controller-Optionen");
|
||||||
|
put("action_move_forward", "Move Forward", "Avancer", "Vorwärts gehen");
|
||||||
|
put("action_move_back", "Move Back", "Reculer", "Rückwärts gehen");
|
||||||
|
put("action_strafe_left", "Strafe Left", "Pas latéral gauche", "Seitwärts links");
|
||||||
|
put("action_strafe_right", "Strafe Right", "Pas latéral droit", "Seitwärts rechts");
|
||||||
|
put("action_turn_left", "Turn Left", "Tourner à gauche", "Nach links drehen");
|
||||||
|
put("action_turn_right", "Turn Right", "Tourner à droite", "Nach rechts drehen");
|
||||||
|
put("action_jump", "Jump", "Sauter", "Springen");
|
||||||
|
put("action_mouse_click", "Click", "Cliquer", "Klicken");
|
||||||
|
put("action_mouse_double_click", "Double-Click", "Double-clic", "Doppelklick");
|
||||||
|
|
||||||
|
// Hotkey actions (see xr_input.c); all unbound by default.
|
||||||
|
put("action_stand", "Stand", "Debout", "Stehen");
|
||||||
|
put("action_crouch", "Crouch", "Accroupi", "Ducken");
|
||||||
|
put("action_prone", "Prone", "Couché", "Liegen");
|
||||||
|
put("action_pause", "Pause Game", "Mettre en pause", "Spiel pausieren");
|
||||||
|
put("action_full_view", "Fullscreen View", "Vue plein écran", "Vollbildansicht");
|
||||||
|
put("action_normal_view", "Normal View", "Vue normale", "Normale Ansicht");
|
||||||
|
put("action_map_view", "Automap View", "Vue de la carte", "Automap-Ansicht");
|
||||||
|
put("action_save_game", "Save Game", "Sauvegarder", "Spiel speichern");
|
||||||
|
put("action_load_game", "Load Game", "Charger", "Spiel laden");
|
||||||
|
put("action_select_grenade", "Select Grenade", "Sélectionner une grenade",
|
||||||
|
"Granate auswählen");
|
||||||
|
put("action_arm_grenade", "Arm Grenade", "Armer la grenade", "Granate scharfmachen");
|
||||||
|
put("action_select_drug", "Select Drug", "Sélectionner une drogue", "Droge auswählen");
|
||||||
|
put("action_use_drug", "Use Drug", "Utiliser la drogue", "Droge benutzen");
|
||||||
|
put("action_toggle_online_help", "Toggle Online Help", "Basculer l'aide en ligne",
|
||||||
|
"Online-Hilfe umschalten");
|
||||||
|
put("action_show_help", "Show Help", "Afficher l'aide", "Hilfe anzeigen");
|
||||||
|
put("action_toggle_music", "Toggle Music", "Basculer la musique", "Musik umschalten");
|
||||||
|
put("action_quit", "Quit Game", "Quitter le jeu", "Spiel beenden");
|
||||||
|
put("action_cycle_weapon_next", "Next Weapon", "Arme suivante", "Nächste Waffe");
|
||||||
|
put("action_cycle_weapon_prev", "Previous Weapon", "Arme précédente", "Vorherige Waffe");
|
||||||
|
put("action_cycle_detail", "Cycle Detail Level", "Changer le niveau de détail",
|
||||||
|
"Detailgrad wechseln");
|
||||||
|
put("action_reload_weapon_primary", "Reload Weapon (Primary)",
|
||||||
|
"Recharger l'arme (principal)", "Waffe nachladen (primär)");
|
||||||
|
put("action_reload_weapon_secondary", "Reload Weapon (Secondary)",
|
||||||
|
"Recharger l'arme (secondaire)", "Waffe nachladen (sekundär)");
|
||||||
|
put("action_hud_color", "Cycle HUD Color", "Changer la couleur de l'interface",
|
||||||
|
"HUD-Farbe wechseln");
|
||||||
|
put("action_clear_fullscreen", "Clear Fullscreen View", "Quitter la vue plein écran",
|
||||||
|
"Vollbildansicht schließen");
|
||||||
|
put("action_mfd_left_1", "Left MFD: Weapon", "MFD gauche : arme", "Linkes MFD: Waffe");
|
||||||
|
put("action_mfd_left_2", "Left MFD: Item", "MFD gauche : objet", "Linkes MFD: Gegenstand");
|
||||||
|
put("action_mfd_left_3", "Left MFD: Map", "MFD gauche : carte", "Linkes MFD: Karte");
|
||||||
|
put("action_mfd_left_4", "Left MFD: Target", "MFD gauche : cible", "Linkes MFD: Ziel");
|
||||||
|
put("action_mfd_left_5", "Left MFD: Info", "MFD gauche : info", "Linkes MFD: Info");
|
||||||
|
put("action_mfd_right_1", "Right MFD: Weapon", "MFD droit : arme", "Rechtes MFD: Waffe");
|
||||||
|
put("action_mfd_right_2", "Right MFD: Item", "MFD droit : objet",
|
||||||
|
"Rechtes MFD: Gegenstand");
|
||||||
|
put("action_mfd_right_3", "Right MFD: Map", "MFD droit : carte", "Rechtes MFD: Karte");
|
||||||
|
put("action_mfd_right_4", "Right MFD: Target", "MFD droit : cible", "Rechtes MFD: Ziel");
|
||||||
|
put("action_mfd_right_5", "Right MFD: Info", "MFD droit : info", "Rechtes MFD: Info");
|
||||||
|
put("action_fire_weapon", "Fire Weapon", "Tirer", "Waffe abfeuern");
|
||||||
|
// Cheats tab.
|
||||||
|
put("cheats", "Cheats", "Triches", "Cheats");
|
||||||
|
put("cheats_note", "Cheats need a running game.", "Les triches nécessitent une partie en cours.",
|
||||||
|
"Cheats benötigen ein laufendes Spiel.");
|
||||||
|
put("cheat_give_all", "Give All Items & Weapons", "Tout obtenir", "Alles erhalten");
|
||||||
|
put("cheat_physics", "Physics", "Physique", "Physik");
|
||||||
|
put("cheat_level_up", "Next Level", "Niveau suivant", "Nächstes Level");
|
||||||
|
put("cheat_level_down", "Previous Level", "Niveau précédent", "Vorheriges Level");
|
||||||
|
put("cheat_unlimited_health", "Unlimited Health", "Santé illimitée", "Unbegrenzt Leben");
|
||||||
|
put("cheat_unlimited_energy", "Unlimited Energy", "Énergie illimitée", "Unbegrenzt Energie");
|
||||||
|
put("cheat_unlimited_ammo", "Unlimited Ammo", "Munitions illimitées", "Unbegrenzt Munition");
|
||||||
|
put("cheat_unlimited_items", "Unlimited Items", "Objets illimités", "Unbegrenzt Items");
|
||||||
|
|
||||||
|
put("action_free_left_thumbstick_view", "Free View (Left Stick)",
|
||||||
|
"Vue libre (stick gauche)", "Freie Ansicht (linker Stick)");
|
||||||
|
put("action_free_right_thumbstick_view", "Free View (Right Stick)",
|
||||||
|
"Vue libre (stick droit)", "Freie Ansicht (rechter Stick)");
|
||||||
|
|
||||||
|
// Cyberspace actions (active only in cyberspace, see xr_input.c).
|
||||||
|
put("action_cyber_thrust", "Cyberspace: Thrust", "Cyberespace : poussée",
|
||||||
|
"Cyberspace: Schub");
|
||||||
|
put("action_cyber_climb", "Cyberspace: Climb", "Cyberespace : monter",
|
||||||
|
"Cyberspace: Steigen");
|
||||||
|
put("action_cyber_dive", "Cyberspace: Dive", "Cyberespace : plonger",
|
||||||
|
"Cyberspace: Sinken");
|
||||||
|
put("action_cyber_bank_left", "Cyberspace: Bank Left", "Cyberespace : virage à gauche",
|
||||||
|
"Cyberspace: Links kippen");
|
||||||
|
put("action_cyber_bank_right", "Cyberspace: Bank Right", "Cyberespace : virage à droite",
|
||||||
|
"Cyberspace: Rechts kippen");
|
||||||
|
put("action_cyber_roll_left", "Cyberspace: Roll Left", "Cyberespace : roulis gauche",
|
||||||
|
"Cyberspace: Links rollen");
|
||||||
|
put("action_cyber_roll_right", "Cyberspace: Roll Right", "Cyberespace : roulis droit",
|
||||||
|
"Cyberspace: Rechts rollen");
|
||||||
|
put("action_cyber_climb_left", "Cyberspace: Climb Left", "Cyberespace : monter à gauche",
|
||||||
|
"Cyberspace: Links steigen");
|
||||||
|
put("action_cyber_climb_right", "Cyberspace: Climb Right", "Cyberespace : monter à droite",
|
||||||
|
"Cyberspace: Rechts steigen");
|
||||||
|
put("action_cyber_dive_left", "Cyberspace: Dive Left", "Cyberespace : plonger à gauche",
|
||||||
|
"Cyberspace: Links sinken");
|
||||||
|
put("action_cyber_dive_right", "Cyberspace: Dive Right", "Cyberespace : plonger à droite",
|
||||||
|
"Cyberspace: Rechts sinken");
|
||||||
|
put("capture_prompt", "Press a button…", "Appuyez sur un bouton…", "Taste drücken…");
|
||||||
|
put("reset_to_defaults", "Reset to Defaults", "Réinitialiser", "Auf Standard zurücksetzen");
|
||||||
|
put("generic_profile_note",
|
||||||
|
"This controller only exposes a trigger and a menu button - most actions above have "
|
||||||
|
+ "nothing to bind to.",
|
||||||
|
"Cette manette n'expose qu'une gâchette et un bouton menu - la plupart des actions "
|
||||||
|
+ "ci-dessus n'ont rien à quoi être assignées.",
|
||||||
|
"Dieser Controller bietet nur einen Trigger und eine Menütaste - die meisten "
|
||||||
|
+ "Aktionen oben haben nichts, woran sie gebunden werden können.");
|
||||||
|
|
||||||
|
// Raw physical-input display names (xr_input.c's kRawInputNames);
|
||||||
|
// "none" is the unbound sentinel.
|
||||||
|
put("raw_left_trigger", "Left Trigger", "Gâchette gauche", "Linker Trigger");
|
||||||
|
put("raw_right_trigger", "Right Trigger", "Gâchette droite", "Rechter Trigger");
|
||||||
|
put("raw_left_squeeze", "Left Grip", "Préhension gauche", "Linker Griff");
|
||||||
|
put("raw_right_squeeze", "Right Grip", "Préhension droite", "Rechter Griff");
|
||||||
|
put("raw_left_x", "X Button", "Bouton X", "X-Taste");
|
||||||
|
put("raw_left_y", "Y Button", "Bouton Y", "Y-Taste");
|
||||||
|
put("raw_right_a", "A Button", "Bouton A", "A-Taste");
|
||||||
|
put("raw_right_b", "B Button", "Bouton B", "B-Taste");
|
||||||
|
put("raw_left_thumbstick_click", "Left Stick Click", "Clic du stick gauche",
|
||||||
|
"Linker Stick-Klick");
|
||||||
|
put("raw_right_thumbstick_click", "Right Stick Click", "Clic du stick droit",
|
||||||
|
"Rechter Stick-Klick");
|
||||||
|
put("raw_left_thumbstick_up", "Left Stick Up", "Stick gauche haut", "Linker Stick hoch");
|
||||||
|
put("raw_left_thumbstick_down", "Left Stick Down", "Stick gauche bas", "Linker Stick runter");
|
||||||
|
put("raw_left_thumbstick_left", "Left Stick Left", "Stick gauche à gauche",
|
||||||
|
"Linker Stick links");
|
||||||
|
put("raw_left_thumbstick_right", "Left Stick Right", "Stick gauche à droite",
|
||||||
|
"Linker Stick rechts");
|
||||||
|
put("raw_right_thumbstick_up", "Right Stick Up", "Stick droit haut", "Rechter Stick hoch");
|
||||||
|
put("raw_right_thumbstick_down", "Right Stick Down", "Stick droit bas",
|
||||||
|
"Rechter Stick runter");
|
||||||
|
put("raw_right_thumbstick_left", "Right Stick Left", "Stick droit à gauche",
|
||||||
|
"Rechter Stick links");
|
||||||
|
put("raw_right_thumbstick_right", "Right Stick Right", "Stick droit à droite",
|
||||||
|
"Rechter Stick rechts");
|
||||||
|
put("raw_trigger", "Either Trigger", "L'une ou l'autre gâchette", "Beliebiger Trigger");
|
||||||
|
put("raw_squeeze", "Either Grip", "L'une ou l'autre préhension", "Beliebiger Griff");
|
||||||
|
put("raw_none", "(unbound)", "(non assigné)", "(nicht zugewiesen)");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Strings() {}
|
||||||
|
|
||||||
|
static String get(String key, int language) {
|
||||||
|
String[] variants = TABLE.get(key);
|
||||||
|
if (variants == null) {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
if (language < 0 || language >= variants.length) {
|
||||||
|
language = ENGLISH;
|
||||||
|
}
|
||||||
|
return variants[language];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 16 KiB |
@@ -1,4 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">System Shock</string>
|
<string name="app_name">QuestShock</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -9,7 +9,18 @@ buildscript {
|
|||||||
google()
|
google()
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
classpath 'com.android.tools.build:gradle:8.1.1'
|
// 8.5.1+ needed for AGP to automatically 16 KB-align native
|
||||||
|
// libraries' zip entries within the APK - the actual .so's are
|
||||||
|
// already ELF-segment-aligned for this regardless of AGP version
|
||||||
|
// (see build-image/Dockerfile's ANDROID_16KB_LDFLAGS and
|
||||||
|
// app/build.gradle's own CMAKE_SHARED_LINKER_FLAGS), but that alone
|
||||||
|
// doesn't get them 16 KB-aligned *within the APK's zip*, which is
|
||||||
|
// what Android Studio's "not compatible with 16 KB devices"
|
||||||
|
// warning is actually about. Needs Gradle 8.7+ (see
|
||||||
|
// gradle/wrapper/gradle-wrapper.properties) and JDK 17 in whatever
|
||||||
|
// runs gradlew (already the case - see build-image/Dockerfile's
|
||||||
|
// openjdk-17-jdk-headless).
|
||||||
|
classpath 'com.android.tools.build:gradle:8.5.2'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
--- a/src/MacSrc/Shock.c
|
--- a/src/MacSrc/Shock.c
|
||||||
+++ b/src/MacSrc/Shock.c
|
+++ b/src/MacSrc/Shock.c
|
||||||
@@ -162,6 +162,17 @@
|
@@ -162,9 +162,26 @@
|
||||||
void InitSDL() {
|
void InitSDL() {
|
||||||
SDL_SetHint(SDL_HINT_NO_SIGNAL_HANDLERS, "1");
|
SDL_SetHint(SDL_HINT_NO_SIGNAL_HANDLERS, "1");
|
||||||
SDL_SetHint(SDL_HINT_RENDER_DRIVER, "opengl");
|
SDL_SetHint(SDL_HINT_RENDER_DRIVER, "opengl");
|
||||||
@@ -12,9 +12,18 @@
|
|||||||
+ // src/audio/aaudio/SDL_aaudio.c). SDLSound.c opens two: one directly via
|
+ // src/audio/aaudio/SDL_aaudio.c). SDLSound.c opens two: one directly via
|
||||||
+ // SDL_OpenAudioDevice() for cutscene audio, one via Mix_OpenAudio() for
|
+ // SDL_OpenAudioDevice() for cutscene audio, one via Mix_OpenAudio() for
|
||||||
+ // SFX/MIDI. The older OpenSL ES backend has no such limitation, so force
|
+ // SFX/MIDI. The older OpenSL ES backend has no such limitation, so force
|
||||||
+ // it instead of AAudio.
|
+ // it instead of AAudio. Uses SDL_HINT_OVERRIDE priority so nothing set
|
||||||
+ SDL_SetHint(SDL_HINT_AUDIODRIVER, "openslES");
|
+ // earlier (or by SDL/the platform) can take precedence over it.
|
||||||
|
+ SDL_SetHintWithPriority(SDL_HINT_AUDIODRIVER, "openslES", SDL_HINT_OVERRIDE);
|
||||||
+#endif
|
+#endif
|
||||||
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_TIMER | SDL_INIT_AUDIO) < 0) {
|
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_TIMER | SDL_INIT_AUDIO) < 0) {
|
||||||
DEBUG("%s: Init failed", __FUNCTION__);
|
DEBUG("%s: Init failed", __FUNCTION__);
|
||||||
}
|
}
|
||||||
|
+#ifdef __ANDROID__
|
||||||
|
+ // Standing diagnostic: reports which audio driver SDL actually selected,
|
||||||
|
+ // to confirm the openslES override above took effect on-device.
|
||||||
|
+ INFO("SDL audio driver: %s", SDL_GetCurrentAudioDriver());
|
||||||
|
+#endif
|
||||||
|
|
||||||
|
// TODO: figure out some universal set of settings that work...
|
||||||
|
#ifdef __ANDROID__
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
--- a/src/MacSrc/ShockBitmap.c
|
--- a/src/MacSrc/ShockBitmap.c
|
||||||
+++ b/src/MacSrc/ShockBitmap.c
|
+++ b/src/MacSrc/ShockBitmap.c
|
||||||
@@ -42,11 +42,26 @@
|
@@ -27,6 +27,9 @@
|
||||||
|
#include "Shock.h"
|
||||||
|
#include "ShockBitmap.h"
|
||||||
|
#include "2d.h"
|
||||||
|
+#ifdef __ANDROID__
|
||||||
|
+#include "OpenGL.h"
|
||||||
|
+#endif
|
||||||
|
|
||||||
|
//--------------------
|
||||||
|
// Globals
|
||||||
|
@@ -42,11 +45,26 @@
|
||||||
|
|
||||||
SDL_RenderClear(renderer);
|
SDL_RenderClear(renderer);
|
||||||
|
|
||||||
@@ -27,3 +37,32 @@
|
|||||||
|
|
||||||
SDL_RenderSetLogicalSize(renderer, width, height);
|
SDL_RenderSetLogicalSize(renderer, width, height);
|
||||||
|
|
||||||
|
@@ -54,6 +72,28 @@
|
||||||
|
|
||||||
|
gScreenWide = width;
|
||||||
|
gScreenHigh = height;
|
||||||
|
+
|
||||||
|
+#ifdef __ANDROID__
|
||||||
|
+ // Skipping SDL_SetWindowSize() above (see the comment for why) also
|
||||||
|
+ // means no native resize event ever fires on Android when the in-game
|
||||||
|
+ // resolution changes - opengl_resize() (OpenGL.cc) only ever runs from
|
||||||
|
+ // that event (see sdl_events.c) or once at OpenGL init, and it's what
|
||||||
|
+ // (re)creates backupBuffer, the offscreen 3D-view render target, sized
|
||||||
|
+ // to the logical resolution just changed above. Without this, it stays
|
||||||
|
+ // stuck at whatever size it was at launch while the rest of the game
|
||||||
|
+ // (drawSurface/gScreenWide/gScreenHigh, just updated above) moves on to
|
||||||
|
+ // the new resolution - the actual cause of the display breaking and
|
||||||
|
+ // becoming unresponsive to further input after changing resolution
|
||||||
|
+ // in-game. Call it directly instead - after gScreenWide/gScreenHigh
|
||||||
|
+ // update above, same as the desktop resize event (delivered via
|
||||||
|
+ // SDL_PollEvent(), so always processed after this function has already
|
||||||
|
+ // returned) - using the same physical window size init_opengl() itself
|
||||||
|
+ // used (SDL_GetWindowSize()); that never changes on Android, only the
|
||||||
|
+ // logical size set above does.
|
||||||
|
+ int physWidth, physHeight;
|
||||||
|
+ SDL_GetWindowSize(window, &physWidth, &physHeight);
|
||||||
|
+ opengl_resize(physWidth, physHeight);
|
||||||
|
+#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
//------------------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -199,11 +199,18 @@
|
|||||||
#include "InitMac.h"
|
#include "InitMac.h"
|
||||||
#include "Modding.h"
|
#include "Modding.h"
|
||||||
#include "OpenGL.h"
|
#include "OpenGL.h"
|
||||||
@@ -301,6 +305,21 @@
|
@@ -301,6 +305,28 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
void SDLDraw() {
|
void SDLDraw() {
|
||||||
+#ifdef __ANDROID__
|
+#ifdef __ANDROID__
|
||||||
|
+ // Diagnostic: logs once per transition (including the very first
|
||||||
|
+ // frame) between the OpenGL 3D render path and the plain
|
||||||
|
+ // software-composited 2D path (splash/menus/cutscenes) below - so a
|
||||||
|
+ // logcat capture immediately shows which one is active without
|
||||||
|
+ // needing to infer it from screenshots.
|
||||||
|
+ static int s_last_render_mode = -1; // -1 = not yet logged
|
||||||
|
+
|
||||||
+ // The true once-a-frame present hook for the immersive OpenXR path -
|
+ // The true once-a-frame present hook for the immersive OpenXR path -
|
||||||
+ // unlike opengl_swap_and_restore() below, this runs every frame
|
+ // unlike opengl_swap_and_restore() below, this runs every frame
|
||||||
+ // regardless of should_opengl_swap() (e.g. also during the splash
|
+ // regardless of should_opengl_swap() (e.g. also during the splash
|
||||||
@@ -221,17 +228,25 @@
|
|||||||
if (should_opengl_swap()) {
|
if (should_opengl_swap()) {
|
||||||
// We want the UI background to be transparent!
|
// We want the UI background to be transparent!
|
||||||
sdlPalette->colors[255].a = 0x00;
|
sdlPalette->colors[255].a = 0x00;
|
||||||
@@ -310,14 +329,27 @@
|
@@ -310,14 +336,35 @@
|
||||||
|
|
||||||
// Set the palette back, and we are done
|
// Set the palette back, and we are done
|
||||||
sdlPalette->colors[255].a = 0xff;
|
sdlPalette->colors[255].a = 0xff;
|
||||||
+#ifdef __ANDROID__
|
+#ifdef __ANDROID__
|
||||||
|
+ if (s_last_render_mode != 1) {
|
||||||
|
+ s_last_render_mode = 1;
|
||||||
|
+ INFO("Game rendering active: OpenGL (3D) mode");
|
||||||
|
+ }
|
||||||
+ xr_frame_end();
|
+ xr_frame_end();
|
||||||
+#endif
|
+#endif
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
+#ifdef __ANDROID__
|
+#ifdef __ANDROID__
|
||||||
|
+ if (s_last_render_mode != 0) {
|
||||||
|
+ s_last_render_mode = 0;
|
||||||
|
+ INFO("Game rendering active: software (2D) mode");
|
||||||
|
+ }
|
||||||
+ // Same software-rendered frame the desktop path below would present
|
+ // Same software-rendered frame the desktop path below would present
|
||||||
+ // via SDL_RenderCopy/SDL_RenderPresent, composited into the XR
|
+ // via SDL_RenderCopy/SDL_RenderPresent, composited into the XR
|
||||||
+ // swapchain image xr_frame_begin() bound above instead - SDL's
|
+ // swapchain image xr_frame_begin() bound above instead - SDL's
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
--- a/src/MacSrc/OpenGL.cc 2026-08-22 09:00:00.000000000 +0200
|
||||||
|
+++ b/src/MacSrc/OpenGL.cc 2026-08-22 09:05:00.000000000 +0200
|
||||||
|
@@ -562,7 +562,14 @@
|
||||||
|
static bool opengl_cache_texture(CachedTexture toCache, grs_bitmap *bm) {
|
||||||
|
SDL_GL_MakeCurrent(window, context);
|
||||||
|
|
||||||
|
- glPixelStorei(GL_UNPACK_ROW_LENGTH, bm->row);
|
||||||
|
+ // GL_UNPACK_ROW_LENGTH must describe the buffer actually passed to
|
||||||
|
+ // glTexImage2D() below (toCache.converted->pixels, a separately
|
||||||
|
+ // allocated SDL_PIXELFORMAT_RGBA32 surface from
|
||||||
|
+ // SDL_ConvertSurfaceFormat()) - not bm->row, the original 8-bit
|
||||||
|
+ // source bitmap's own unrelated row stride. Using bm->row here fed
|
||||||
|
+ // gl4es's GL_UNPACK_ROW_LENGTH emulation the wrong stride whenever
|
||||||
|
+ // the two differed, corrupting the texture upload.
|
||||||
|
+ glPixelStorei(GL_UNPACK_ROW_LENGTH, toCache.converted->pitch / 4);
|
||||||
|
|
||||||
|
if (texturesByBitsPtr.size() < MAX_CACHED_TEXTURES) {
|
||||||
|
// We have enough room, generate the new texture
|
||||||
|
@@ -696,7 +703,10 @@
|
||||||
|
t->lastDrawTime = *tmd_ticks;
|
||||||
|
|
||||||
|
if (isDirty) {
|
||||||
|
- glPixelStorei(GL_UNPACK_ROW_LENGTH, bm->row);
|
||||||
|
+ // Same fix as opengl_cache_texture() above - t->converted->pixels
|
||||||
|
+ // is what's actually uploaded, so its own pitch is what
|
||||||
|
+ // GL_UNPACK_ROW_LENGTH needs to describe, not bm->row.
|
||||||
|
+ glPixelStorei(GL_UNPACK_ROW_LENGTH, t->converted->pitch / 4);
|
||||||
|
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, bm->w, bm->h, 0, GL_RGBA, GL_UNSIGNED_BYTE, t->converted->pixels);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
--- a/src/MacSrc/OpenGL.cc
|
||||||
|
+++ b/src/MacSrc/OpenGL.cc
|
||||||
|
@@ -566,7 +566,11 @@
|
||||||
|
glDisableVertexAttribArray(0);
|
||||||
|
glDisableVertexAttribArray(tcAttrib);
|
||||||
|
glDisableVertexAttribArray(lightAttrib);
|
||||||
|
- glFlush();
|
||||||
|
+ // glFinish(), not glFlush(): xr_swapchain_release() (called by our
|
||||||
|
+ // caller right after this returns) hands the image to the OpenXR
|
||||||
|
+ // runtime for cross-process consumption, which requires the GPU write
|
||||||
|
+ // to have actually landed, not just be queued.
|
||||||
|
+ glFinish();
|
||||||
|
|
||||||
|
SDL_FreeSurface(uiRgba);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
--- a/src/MacSrc/OpenGL.cc
|
||||||
|
+++ b/src/MacSrc/OpenGL.cc
|
||||||
|
@@ -525,6 +525,13 @@
|
||||||
|
bind_texture(s_uiTexture);
|
||||||
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
|
||||||
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
|
||||||
|
+ // uiRgba is tightly packed, but gl4es implements GL_UNPACK_ROW_LENGTH/
|
||||||
|
+ // GL_UNPACK_ALIGNMENT client-side and persists whatever was last set
|
||||||
|
+ // across draw calls in the same context. Reset both to the packed
|
||||||
|
+ // defaults here so this upload doesn't inherit unrelated unpack state
|
||||||
|
+ // set elsewhere in the frame.
|
||||||
|
+ glPixelStorei(GL_UNPACK_ROW_LENGTH, 0);
|
||||||
|
+ glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
|
||||||
|
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, uiRgba->w, uiRgba->h, 0, GL_RGBA, GL_UNSIGNED_BYTE,
|
||||||
|
uiRgba->pixels);
|
||||||
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
--- a/src/MacSrc/SDLSound.c
|
||||||
|
+++ b/src/MacSrc/SDLSound.c
|
||||||
|
@@ -42,7 +42,7 @@
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Mix_OpenAudio(48000, AUDIO_S16SYS, 2, 2048) < 0) {
|
||||||
|
- ERROR("%s: Couldn't open audio device", __FUNCTION__);
|
||||||
|
+ ERROR("%s: Couldn't open audio device: %s", __FUNCTION__, SDL_GetError());
|
||||||
|
}
|
||||||
|
|
||||||
|
Mix_AllocateChannels(SND_MAX_SAMPLES);
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
--- a/src/MacSrc/SDLSound.c
|
||||||
|
+++ b/src/MacSrc/SDLSound.c
|
||||||
|
@@ -19,24 +19,6 @@
|
||||||
|
|
||||||
|
// Startup the sound system
|
||||||
|
|
||||||
|
- SDL_AudioSpec spec, obtained;
|
||||||
|
- spec.freq = 48000;
|
||||||
|
- spec.format = AUDIO_S16SYS;
|
||||||
|
- spec.channels = 2;
|
||||||
|
- spec.samples = 2048;
|
||||||
|
- spec.callback = AudioStreamCallback;
|
||||||
|
- spec.userdata = (void *)&cutscene_audiostream;
|
||||||
|
-
|
||||||
|
- extern SDL_AudioDeviceID device;
|
||||||
|
- device = SDL_OpenAudioDevice(NULL, 0, &spec, &obtained, 0);
|
||||||
|
-
|
||||||
|
- if (device == 0) {
|
||||||
|
- ERROR("Could not open SDL audio: %s", SDL_GetError());
|
||||||
|
- } else {
|
||||||
|
- INFO("Opened Music Stream, deviceID %d, freq %d, size %d, format %d, channels %d, samples %d", device,
|
||||||
|
- obtained.freq, obtained.size, obtained.format, obtained.channels, obtained.samples);
|
||||||
|
- }
|
||||||
|
-
|
||||||
|
if (Mix_Init(MIX_INIT_MP3) < 0) {
|
||||||
|
ERROR("%s: Init failed", __FUNCTION__);
|
||||||
|
}
|
||||||
|
@@ -50,10 +32,18 @@
|
||||||
|
Mix_HookMusic(MusicCallback, (void *)&MusicDev);
|
||||||
|
Mix_VolumeMusic(MIX_MAX_VOLUME); // use max volume for music stream
|
||||||
|
|
||||||
|
+ // Cutscene/audiolog playback (AudioStreamCallback, see cutsloop.c) used to
|
||||||
|
+ // run through its own SDL_OpenAudioDevice() device, separate from this
|
||||||
|
+ // one - SDL only ever allows one open non-capture device on Android's
|
||||||
|
+ // audio backends, so that second open always failed with "Audio device
|
||||||
|
+ // already open" and both SFX and MIDI music (both routed through this
|
||||||
|
+ // Mix_OpenAudio device) silently never played. Mixed into this device's
|
||||||
|
+ // post-mix stage instead, so there is only ever one device open.
|
||||||
|
+ Mix_SetPostMix(AudioStreamCallback, (void *)&cutscene_audiostream);
|
||||||
|
+
|
||||||
|
InitReadXMI();
|
||||||
|
|
||||||
|
atexit(Mix_CloseAudio);
|
||||||
|
- atexit(SDL_CloseAudio);
|
||||||
|
|
||||||
|
return OK;
|
||||||
|
}
|
||||||
|
--- a/src/MacSrc/Shock.c
|
||||||
|
+++ b/src/MacSrc/Shock.c
|
||||||
|
@@ -60,8 +60,6 @@
|
||||||
|
SDL_Palette *sdlPalette;
|
||||||
|
SDL_Renderer *renderer;
|
||||||
|
|
||||||
|
-SDL_AudioDeviceID device;
|
||||||
|
-
|
||||||
|
int num_args;
|
||||||
|
char **arg_values;
|
||||||
|
|
||||||
|
--- a/src/GameSrc/cutsloop.c
|
||||||
|
+++ b/src/GameSrc/cutsloop.c
|
||||||
|
@@ -83,15 +83,25 @@
|
||||||
|
extern char FrnSubtitle[256];
|
||||||
|
extern char GerSubtitle[256];
|
||||||
|
|
||||||
|
-extern SDL_AudioDeviceID device;
|
||||||
|
-
|
||||||
|
-
|
||||||
|
+// Runs as SDL_mixer's post-mix hook (see snd_start_digital() in
|
||||||
|
+// SDLSound.c) rather than a dedicated SDL audio device's callback, so
|
||||||
|
+// SFX/MIDI music sharing the same device are already mixed into `stream`
|
||||||
|
+// by the time this runs - mix in rather than overwrite, so they aren't
|
||||||
|
+// silenced whenever a cutscene or audiolog is playing.
|
||||||
|
void AudioStreamCallback(void *userdata, unsigned char *stream, int len)
|
||||||
|
{
|
||||||
|
+ static Uint8 mixbuf[MOVIE_DEFAULT_BLOCKLEN * 4];
|
||||||
|
SDL_AudioStream *as = *(SDL_AudioStream **)userdata;
|
||||||
|
|
||||||
|
- if (as != NULL && SDL_AudioStreamAvailable(as) > 0)
|
||||||
|
- SDL_AudioStreamGet(as, stream, len);
|
||||||
|
+ if (as == NULL || SDL_AudioStreamAvailable(as) <= 0)
|
||||||
|
+ return;
|
||||||
|
+
|
||||||
|
+ if (len > (int)sizeof(mixbuf))
|
||||||
|
+ len = (int)sizeof(mixbuf);
|
||||||
|
+
|
||||||
|
+ int got = SDL_AudioStreamGet(as, mixbuf, len);
|
||||||
|
+ if (got > 0)
|
||||||
|
+ SDL_MixAudioFormat(stream, mixbuf, AUDIO_S16SYS, got, SDL_MIX_MAXVOLUME);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@@ -153,11 +163,13 @@
|
||||||
|
|
||||||
|
if (cutscene_audiostream != NULL)
|
||||||
|
{
|
||||||
|
- SDL_PauseAudioDevice(device, 1);
|
||||||
|
- SDL_Delay(1);
|
||||||
|
-
|
||||||
|
+ // AudioStreamCallback (SDL_mixer's post-mix hook) runs continuously on
|
||||||
|
+ // SDL_mixer's own audio thread and reads this pointer - lock around
|
||||||
|
+ // freeing it so that thread can't be mid-read when it's freed.
|
||||||
|
+ SDL_LockAudio();
|
||||||
|
SDL_FreeAudioStream(cutscene_audiostream);
|
||||||
|
cutscene_audiostream = NULL;
|
||||||
|
+ SDL_UnlockAudio();
|
||||||
|
|
||||||
|
if (cutscene_audiobuffer)
|
||||||
|
{
|
||||||
|
@@ -184,8 +196,6 @@
|
||||||
|
|
||||||
|
if (cutscene_audiostream)
|
||||||
|
{
|
||||||
|
- SDL_PauseAudioDevice(device, 0);
|
||||||
|
-
|
||||||
|
if (cutscene_audiobuffer_size > 0)
|
||||||
|
{
|
||||||
|
// === adjust volume in buffer here ===
|
||||||
|
@@ -325,10 +335,9 @@
|
||||||
|
|
||||||
|
if (sfx_on)
|
||||||
|
{
|
||||||
|
- SDL_PauseAudioDevice(device, 1);
|
||||||
|
- SDL_Delay(1);
|
||||||
|
-
|
||||||
|
+ SDL_LockAudio();
|
||||||
|
cutscene_audiostream = SDL_NewAudioStream(AUDIO_U8, 1, fix_int(amovie->a.sampleRate), AUDIO_S16SYS, 2, 48000);
|
||||||
|
+ SDL_UnlockAudio();
|
||||||
|
|
||||||
|
cutscene_audiobuffer_pos = cutscene_audiobuffer;
|
||||||
|
}
|
||||||
|
--- a/src/GameSrc/audiolog.c
|
||||||
|
+++ b/src/GameSrc/audiolog.c
|
||||||
|
@@ -61,8 +61,6 @@
|
||||||
|
extern uchar curr_alog_vol;
|
||||||
|
extern char which_lang;
|
||||||
|
|
||||||
|
-extern SDL_AudioDeviceID device;
|
||||||
|
-
|
||||||
|
errtype audiolog_init(void) { return OK; }
|
||||||
|
|
||||||
|
errtype audiolog_play(int email_id) {
|
||||||
|
@@ -114,10 +112,9 @@
|
||||||
|
|
||||||
|
DEBUG("%s: Playing email", __FUNCTION__);
|
||||||
|
|
||||||
|
- SDL_PauseAudioDevice(device, 1);
|
||||||
|
- SDL_Delay(1);
|
||||||
|
-
|
||||||
|
+ SDL_LockAudio();
|
||||||
|
cutscene_audiostream = SDL_NewAudioStream(AUDIO_U8, 1, fix_int(palog->a.sampleRate), AUDIO_S16SYS, 2, 48000);
|
||||||
|
+ SDL_UnlockAudio();
|
||||||
|
|
||||||
|
audiolog_audiobuffer_pos = audiolog_audiobuffer;
|
||||||
|
|
||||||
|
@@ -150,11 +147,14 @@
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cutscene_audiostream != NULL) {
|
||||||
|
- SDL_PauseAudioDevice(device, 1);
|
||||||
|
- SDL_Delay(1);
|
||||||
|
-
|
||||||
|
+ // AudioStreamCallback (SDL_mixer's post-mix hook, see cutsloop.c)
|
||||||
|
+ // runs continuously on SDL_mixer's own audio thread and reads this
|
||||||
|
+ // pointer - lock around freeing it so that thread can't be
|
||||||
|
+ // mid-read when it's freed.
|
||||||
|
+ SDL_LockAudio();
|
||||||
|
SDL_FreeAudioStream(cutscene_audiostream);
|
||||||
|
cutscene_audiostream = NULL;
|
||||||
|
+ SDL_UnlockAudio();
|
||||||
|
|
||||||
|
if (audiolog_audiobuffer) {
|
||||||
|
free(audiolog_audiobuffer);
|
||||||
|
@@ -177,8 +177,6 @@
|
||||||
|
|
||||||
|
errtype audiolog_loop_callback(void) {
|
||||||
|
if (cutscene_audiostream) {
|
||||||
|
- SDL_PauseAudioDevice(device, 0);
|
||||||
|
-
|
||||||
|
if (audiolog_audiobuffer_size > 0) {
|
||||||
|
int i, vol = curr_alog_vol * 127 / 100; // convert from 0-100 to 0-127
|
||||||
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
--- a/src/MusicSrc/MusicDevice.c
|
||||||
|
+++ b/src/MusicSrc/MusicDevice.c
|
||||||
|
@@ -569,6 +569,14 @@
|
||||||
|
// connected
|
||||||
|
ndev->dev.isOpen = 1;
|
||||||
|
ndev->dev.outputIndex = outputIndex;
|
||||||
|
+#else
|
||||||
|
+ // No native MIDI backend implemented for this platform (e.g. Android) -
|
||||||
|
+ // report failure so InitDecXMI()'s caller falls back to another device
|
||||||
|
+ // instead of treating an unopened device as successfully initialized.
|
||||||
|
+ (void)outputIndex;
|
||||||
|
+ (void)samplerate;
|
||||||
|
+ WARN("NativeMidiInit(): native MIDI is not implemented on this platform");
|
||||||
|
+ return -1;
|
||||||
|
#endif
|
||||||
|
// suppress compiler warnings
|
||||||
|
(void)outputIndex;
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
--- a/src/MacSrc/OpenGL.cc
|
||||||
|
+++ b/src/MacSrc/OpenGL.cc
|
||||||
|
@@ -391,7 +391,16 @@
|
||||||
|
}
|
||||||
|
|
||||||
|
void opengl_resize(int width, int height) {
|
||||||
|
+#ifndef __ANDROID__
|
||||||
|
+ // Skipped on Android - see opengl_swap_and_restore()'s comment below for
|
||||||
|
+ // why re-asserting an already-current context/surface every call breaks
|
||||||
|
+ // gl4es's own "current framebuffer" bookkeeping, even though the real
|
||||||
|
+ // driver binding never actually changes. This turned out to be the
|
||||||
|
+ // actual source of the persistent per-frame GL_INVALID_OPERATION (1282)
|
||||||
|
+ // that plagued 3D rendering: this same unconditional call was present at
|
||||||
|
+ // the top of nearly every function in this file, not just here.
|
||||||
|
SDL_GL_MakeCurrent(window, context);
|
||||||
|
+#endif
|
||||||
|
|
||||||
|
int logical_width, logical_height;
|
||||||
|
SDL_RenderGetLogicalSize(renderer, &logical_width, &logical_height);
|
||||||
|
@@ -443,7 +452,10 @@
|
||||||
|
}
|
||||||
|
|
||||||
|
void opengl_end_frame() {
|
||||||
|
+#ifndef __ANDROID__
|
||||||
|
+ // See opengl_resize()'s comment above.
|
||||||
|
SDL_GL_MakeCurrent(window, context);
|
||||||
|
+#endif
|
||||||
|
|
||||||
|
// Done rendering to the frame buffer, reset back to normal
|
||||||
|
BindFrameBuffer(nullptr);
|
||||||
|
@@ -470,7 +482,10 @@
|
||||||
|
static bool nightsight_active() { return WareActive(player_struct.hardwarez_status[HARDWARE_GOGGLE_INFRARED]); }
|
||||||
|
|
||||||
|
void opengl_start_frame() {
|
||||||
|
+#ifndef __ANDROID__
|
||||||
|
+ // See opengl_resize()'s comment above.
|
||||||
|
SDL_GL_MakeCurrent(window, context);
|
||||||
|
+#endif
|
||||||
|
|
||||||
|
// Start rendering to our frame buffer canvas
|
||||||
|
BindFrameBuffer(&backupBuffer);
|
||||||
|
@@ -500,6 +515,130 @@
|
||||||
|
}
|
||||||
|
|
||||||
|
#ifdef __ANDROID__
|
||||||
|
+// Real per-vertex arrays, not glBegin/glVertexAttrib/glVertex3f immediate
|
||||||
|
+// mode: glVertexAttrib*() is GLES2's *constant*-attribute API - without an
|
||||||
|
+// enabled vertex array it sets one value for the whole draw call, not a
|
||||||
|
+// fresh value per vertex. gl4es's immediate-mode emulation only reproduces
|
||||||
|
+// desktop GL's per-vertex-capture behavior for attribute 0 (position,
|
||||||
|
+// driven directly by glVertex3f), not for custom attributes like this
|
||||||
|
+// shader's texcoords/light. That means every one of this file's
|
||||||
|
+// glBegin(GL_TRIANGLE_STRIP)-based draws - not just the UI-overlay quad
|
||||||
|
+// this was first found on, but also opengl_light_tmap() (every textured
|
||||||
|
+// wall/floor polygon), opengl_bitmap() (sprites) and opengl_draw_poly()
|
||||||
|
+// (flat/gouraud polygons) via draw_vertex() - draws every vertex with
|
||||||
|
+// whatever texcoord/light was set for the *last* vertex instead of its
|
||||||
|
+// own, which is what actually produced flat-colored/untextured world
|
||||||
|
+// geometry after this patch's first revision only fixed the final
|
||||||
|
+// screen-composite quad (the one bug this was originally found on).
|
||||||
|
+//
|
||||||
|
+// android_imm_begin()/android_imm_vertex()/android_imm_end() below emulate
|
||||||
|
+// just enough of glBegin(GL_TRIANGLE_STRIP)/glVertexAttrib*()/glVertex3f()
|
||||||
|
+// immediate mode to replace every such call site on Android, by buffering
|
||||||
|
+// vertices and flushing them as one real glDrawArrays() call - the engine's
|
||||||
|
+// other rendering still uses real immediate mode for attribute 0 elsewhere
|
||||||
|
+// on desktop builds, which depends on it not having an enabled array bound,
|
||||||
|
+// so android_imm_end() always disables the arrays it enables before
|
||||||
|
+// returning.
|
||||||
|
+struct AndroidImmVertex {
|
||||||
|
+ float x, y, z;
|
||||||
|
+ float u, v;
|
||||||
|
+ float light;
|
||||||
|
+};
|
||||||
|
+
|
||||||
|
+// Arbitrarily-sized, but generous: the engine's polygons (room walls/
|
||||||
|
+// floors, sprites, screen-filling quads) are all small, hand-authored
|
||||||
|
+// shapes, never anywhere near this many vertices.
|
||||||
|
+static AndroidImmVertex s_immVerts[64];
|
||||||
|
+static int s_immVertCount;
|
||||||
|
+static GLint s_immTcAttrib;
|
||||||
|
+static GLint s_immLightAttrib;
|
||||||
|
+
|
||||||
|
+// tcAttrib < 0 means this draw has no texcoord attribute (flat/gouraud
|
||||||
|
+// polygons via opengl_draw_poly()) - matches draw_vertex()'s own
|
||||||
|
+// `tcAttrib >= 0` check below.
|
||||||
|
+static void android_imm_begin(GLint tcAttrib, GLint lightAttrib) {
|
||||||
|
+ s_immVertCount = 0;
|
||||||
|
+ s_immTcAttrib = tcAttrib;
|
||||||
|
+ s_immLightAttrib = lightAttrib;
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+static void android_imm_vertex(float x, float y, float z, float u, float v, float light) {
|
||||||
|
+ if (s_immVertCount >= (int)(sizeof(s_immVerts) / sizeof(s_immVerts[0]))) {
|
||||||
|
+ WARN("android_imm_vertex: dropping vertex past the %d-vertex limit", s_immVertCount);
|
||||||
|
+ return;
|
||||||
|
+ }
|
||||||
|
+ AndroidImmVertex &vert = s_immVerts[s_immVertCount++];
|
||||||
|
+ vert.x = x;
|
||||||
|
+ vert.y = y;
|
||||||
|
+ vert.z = z;
|
||||||
|
+ vert.u = u;
|
||||||
|
+ vert.v = v;
|
||||||
|
+ vert.light = light;
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+static void android_imm_end() {
|
||||||
|
+ if (s_immVertCount < 3)
|
||||||
|
+ return;
|
||||||
|
+ glEnableVertexAttribArray(0);
|
||||||
|
+ if (s_immTcAttrib >= 0)
|
||||||
|
+ glEnableVertexAttribArray(s_immTcAttrib);
|
||||||
|
+ glEnableVertexAttribArray(s_immLightAttrib);
|
||||||
|
+ glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(AndroidImmVertex), &s_immVerts[0].x);
|
||||||
|
+ if (s_immTcAttrib >= 0)
|
||||||
|
+ glVertexAttribPointer(s_immTcAttrib, 2, GL_FLOAT, GL_FALSE, sizeof(AndroidImmVertex),
|
||||||
|
+ &s_immVerts[0].u);
|
||||||
|
+ glVertexAttribPointer(s_immLightAttrib, 1, GL_FLOAT, GL_FALSE, sizeof(AndroidImmVertex),
|
||||||
|
+ &s_immVerts[0].light);
|
||||||
|
+ glDrawArrays(GL_TRIANGLE_STRIP, 0, s_immVertCount);
|
||||||
|
+ glDisableVertexAttribArray(0);
|
||||||
|
+ if (s_immTcAttrib >= 0)
|
||||||
|
+ glDisableVertexAttribArray(s_immTcAttrib);
|
||||||
|
+ glDisableVertexAttribArray(s_immLightAttrib);
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+// Same underlying bug as android_imm_*() above, for opengl_draw_star():
|
||||||
|
+// opengl_begin_stars()/opengl_draw_star()/opengl_end_stars() draw up to
|
||||||
|
+// NUM_STARS (rendtool.c) individual GL_POINTS between one begin/end pair,
|
||||||
|
+// each with its own per-star brightness via glVertexAttrib1f(lightAttrib,
|
||||||
|
+// ...)/glVertex3f() immediate mode - under gl4es that made every star on
|
||||||
|
+// screen render at whatever brightness was set for the *last* one. Shaped
|
||||||
|
+// differently enough from android_imm_*() above (GL_POINTS instead of
|
||||||
|
+// GL_TRIANGLE_STRIP, far more vertices, no texcoord) to warrant its own
|
||||||
|
+// small buffer rather than overloading that one.
|
||||||
|
+static AndroidImmVertex s_starVerts[1000]; // NUM_STARS, GameSrc/rendtool.c
|
||||||
|
+static int s_starVertCount;
|
||||||
|
+static GLint s_starLightAttrib;
|
||||||
|
+
|
||||||
|
+static void android_stars_begin(GLint lightAttrib) {
|
||||||
|
+ s_starVertCount = 0;
|
||||||
|
+ s_starLightAttrib = lightAttrib;
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+static void android_stars_vertex(float x, float y, float z, float light) {
|
||||||
|
+ if (s_starVertCount >= (int)(sizeof(s_starVerts) / sizeof(s_starVerts[0]))) {
|
||||||
|
+ WARN("android_stars_vertex: dropping star past the %d-star limit", s_starVertCount);
|
||||||
|
+ return;
|
||||||
|
+ }
|
||||||
|
+ AndroidImmVertex &vert = s_starVerts[s_starVertCount++];
|
||||||
|
+ vert.x = x;
|
||||||
|
+ vert.y = y;
|
||||||
|
+ vert.z = z;
|
||||||
|
+ vert.light = light;
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+static void android_stars_end() {
|
||||||
|
+ if (s_starVertCount == 0)
|
||||||
|
+ return;
|
||||||
|
+ glEnableVertexAttribArray(0);
|
||||||
|
+ glEnableVertexAttribArray(s_starLightAttrib);
|
||||||
|
+ glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(AndroidImmVertex), &s_starVerts[0].x);
|
||||||
|
+ glVertexAttribPointer(s_starLightAttrib, 1, GL_FLOAT, GL_FALSE, sizeof(AndroidImmVertex),
|
||||||
|
+ &s_starVerts[0].light);
|
||||||
|
+ glDrawArrays(GL_POINTS, 0, s_starVertCount);
|
||||||
|
+ glDisableVertexAttribArray(0);
|
||||||
|
+ glDisableVertexAttribArray(s_starLightAttrib);
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
// Shared by opengl_swap_and_restore()'s UI-overlay blit and
|
||||||
|
// android_composite_software_frame() below - draws an SDL_Surface as a
|
||||||
|
// fullscreen textured quad via gl4es. SDL's own renderer can't be used
|
||||||
|
@@ -537,42 +676,15 @@
|
||||||
|
|
||||||
|
set_blend_mode(blend);
|
||||||
|
|
||||||
|
- // Real per-vertex arrays, not glBegin/glVertexAttrib/glVertex3f
|
||||||
|
- // immediate mode: glVertexAttrib*() is GLES2's *constant*-attribute
|
||||||
|
- // API - without an enabled vertex array it sets one value for the
|
||||||
|
- // whole draw call, not a fresh value per vertex. gl4es's immediate-mode
|
||||||
|
- // emulation only reproduces desktop GL's per-vertex-capture behavior
|
||||||
|
- // for attribute 0 (position, driven directly by glVertex3f), not for
|
||||||
|
- // custom attributes like this shader's texcoords/light.
|
||||||
|
- //
|
||||||
|
// V is flipped (1 at the bottom, 0 at the top): GL texture v=0
|
||||||
|
// addresses the first row of data passed to glTexImage2D, but uiRgba
|
||||||
|
// (an SDL surface) stores its rows top-down.
|
||||||
|
- struct QuadVertex {
|
||||||
|
- float x, y, z;
|
||||||
|
- float u, v;
|
||||||
|
- float light;
|
||||||
|
- };
|
||||||
|
- static const QuadVertex quadVerts[4] = {
|
||||||
|
- {1.0f, -1.0f, 0.0f, 1.0f, 1.0f, 1.0f},
|
||||||
|
- {1.0f, 1.0f, 0.0f, 1.0f, 0.0f, 1.0f},
|
||||||
|
- {-1.0f, -1.0f, 0.0f, 0.0f, 1.0f, 1.0f},
|
||||||
|
- {-1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f},
|
||||||
|
- };
|
||||||
|
- glEnableVertexAttribArray(0);
|
||||||
|
- glEnableVertexAttribArray(tcAttrib);
|
||||||
|
- glEnableVertexAttribArray(lightAttrib);
|
||||||
|
- glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(QuadVertex), &quadVerts[0].x);
|
||||||
|
- glVertexAttribPointer(tcAttrib, 2, GL_FLOAT, GL_FALSE, sizeof(QuadVertex), &quadVerts[0].u);
|
||||||
|
- glVertexAttribPointer(lightAttrib, 1, GL_FLOAT, GL_FALSE, sizeof(QuadVertex),
|
||||||
|
- &quadVerts[0].light);
|
||||||
|
- glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
|
||||||
|
- // The engine's other rendering uses glBegin/glVertexAttrib/glVertex3f
|
||||||
|
- // immediate mode for attribute 0, which depends on it not having an
|
||||||
|
- // enabled array bound.
|
||||||
|
- glDisableVertexAttribArray(0);
|
||||||
|
- glDisableVertexAttribArray(tcAttrib);
|
||||||
|
- glDisableVertexAttribArray(lightAttrib);
|
||||||
|
+ android_imm_begin(tcAttrib, lightAttrib);
|
||||||
|
+ android_imm_vertex(1.0f, -1.0f, 0.0f, 1.0f, 1.0f, 1.0f);
|
||||||
|
+ android_imm_vertex(1.0f, 1.0f, 0.0f, 1.0f, 0.0f, 1.0f);
|
||||||
|
+ android_imm_vertex(-1.0f, -1.0f, 0.0f, 0.0f, 1.0f, 1.0f);
|
||||||
|
+ android_imm_vertex(-1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f);
|
||||||
|
+ android_imm_end();
|
||||||
|
// glFinish(), not glFlush(): xr_swapchain_release() (called by our
|
||||||
|
// caller right after this returns) hands the image to the OpenXR
|
||||||
|
// runtime for cross-process consumption, which requires the GPU write
|
||||||
|
@@ -642,6 +754,16 @@
|
||||||
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
|
||||||
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
|
||||||
|
|
||||||
|
+#ifdef __ANDROID__
|
||||||
|
+ // Real per-vertex arrays, not glBegin/glVertexAttrib/glVertex3f
|
||||||
|
+ // immediate mode - see android_imm_begin()'s comment above for why.
|
||||||
|
+ android_imm_begin(tcAttrib, lightAttrib);
|
||||||
|
+ android_imm_vertex(1.0f, -1.0f, 0.0f, 1.0f, 0.0f, 1.0f);
|
||||||
|
+ android_imm_vertex(1.0f, 1.0f, 0.0f, 1.0f, 1.0f, 1.0f);
|
||||||
|
+ android_imm_vertex(-1.0f, -1.0f, 0.0f, 0.0f, 0.0f, 1.0f);
|
||||||
|
+ android_imm_vertex(-1.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f);
|
||||||
|
+ android_imm_end();
|
||||||
|
+#else
|
||||||
|
glBegin(GL_TRIANGLE_STRIP);
|
||||||
|
glVertexAttrib2f(tcAttrib, 1.0f, 0.0f);
|
||||||
|
glVertexAttrib1f(lightAttrib, 1.0f);
|
||||||
|
@@ -656,6 +778,7 @@
|
||||||
|
glVertexAttrib1f(lightAttrib, 1.0f);
|
||||||
|
glVertex3f(-1.0f, 1.0f, 0.0f);
|
||||||
|
glEnd();
|
||||||
|
+#endif
|
||||||
|
|
||||||
|
// Finish drawing the 3d view
|
||||||
|
glFlush();
|
||||||
|
@@ -709,7 +832,10 @@
|
||||||
|
render_width = width;
|
||||||
|
render_height = height;
|
||||||
|
|
||||||
|
+#ifndef __ANDROID__
|
||||||
|
+ // See opengl_resize()'s comment above.
|
||||||
|
SDL_GL_MakeCurrent(window, context);
|
||||||
|
+#endif
|
||||||
|
|
||||||
|
int lw, lh;
|
||||||
|
SDL_RenderGetLogicalSize(renderer, &lw, &lh);
|
||||||
|
@@ -729,7 +855,10 @@
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool opengl_cache_texture(CachedTexture toCache, grs_bitmap *bm) {
|
||||||
|
+#ifndef __ANDROID__
|
||||||
|
+ // See opengl_resize()'s comment above.
|
||||||
|
SDL_GL_MakeCurrent(window, context);
|
||||||
|
+#endif
|
||||||
|
|
||||||
|
// GL_UNPACK_ROW_LENGTH must describe the buffer actually passed to
|
||||||
|
// glTexImage2D() below (toCache.converted->pixels, a separately
|
||||||
|
@@ -771,7 +900,10 @@
|
||||||
|
|
||||||
|
DEBUG("Clearing OpenGL texture cache.");
|
||||||
|
|
||||||
|
+#ifndef __ANDROID__
|
||||||
|
+ // See opengl_resize()'s comment above.
|
||||||
|
SDL_GL_MakeCurrent(window, context);
|
||||||
|
+#endif
|
||||||
|
|
||||||
|
for (auto iter = texturesByBitsPtr.begin(); iter != texturesByBitsPtr.end();) {
|
||||||
|
// don't free locked surfaces
|
||||||
|
@@ -896,10 +1028,16 @@
|
||||||
|
light = 1.0f - (clut - grd_screen->ltab) / 4096.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
+#ifdef __ANDROID__
|
||||||
|
+ (void)tcAttrib; // captured by android_imm_begin() instead - see its comment above
|
||||||
|
+ android_imm_vertex(vertex.x / 65536.0f, vertex.y / 65536.0f, -vertex.z / 65536.0f,
|
||||||
|
+ vertex.uv.u / 256.0f, vertex.uv.v / 256.0f, light);
|
||||||
|
+#else
|
||||||
|
if (tcAttrib >= 0)
|
||||||
|
glVertexAttrib2f(tcAttrib, vertex.uv.u / 256.0, vertex.uv.v / 256.0);
|
||||||
|
glVertexAttrib1f(lightAttrib, light);
|
||||||
|
glVertex3f(vertex.x / 65536.0f, vertex.y / 65536.0f, -vertex.z / 65536.0f);
|
||||||
|
+#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
int opengl_draw_tmap(int n, g3s_phandle *vp, grs_bitmap *bm) { return opengl_light_tmap(n, vp, bm); }
|
||||||
|
@@ -910,7 +1048,10 @@
|
||||||
|
return CLIP_ALL;
|
||||||
|
}
|
||||||
|
|
||||||
|
+#ifndef __ANDROID__
|
||||||
|
+ // See opengl_resize()'s comment above.
|
||||||
|
SDL_GL_MakeCurrent(window, context);
|
||||||
|
+#endif
|
||||||
|
set_blend_mode(bm->flags & BMF_TRANS);
|
||||||
|
|
||||||
|
glUseProgram(textureShaderProgram.shaderProgram);
|
||||||
|
@@ -928,13 +1069,21 @@
|
||||||
|
glUniformMatrix4fv(textureShaderProgram.uniView, 1, false, ViewMatrix);
|
||||||
|
glUniformMatrix4fv(textureShaderProgram.uniProj, 1, false, ProjectionMatrix);
|
||||||
|
|
||||||
|
+#ifdef __ANDROID__
|
||||||
|
+ android_imm_begin(tcAttrib, lightAttrib);
|
||||||
|
+#else
|
||||||
|
glBegin(GL_TRIANGLE_STRIP);
|
||||||
|
+#endif
|
||||||
|
draw_vertex(*(vp[1]), tcAttrib, lightAttrib);
|
||||||
|
draw_vertex(*(vp[0]), tcAttrib, lightAttrib);
|
||||||
|
draw_vertex(*(vp[2]), tcAttrib, lightAttrib);
|
||||||
|
if (n > 3)
|
||||||
|
draw_vertex(*(vp[3]), tcAttrib, lightAttrib);
|
||||||
|
+#ifdef __ANDROID__
|
||||||
|
+ android_imm_end();
|
||||||
|
+#else
|
||||||
|
glEnd();
|
||||||
|
+#endif
|
||||||
|
|
||||||
|
return CLIP_NONE;
|
||||||
|
}
|
||||||
|
@@ -949,7 +1098,10 @@
|
||||||
|
return CLIP_ALL;
|
||||||
|
}
|
||||||
|
|
||||||
|
+#ifndef __ANDROID__
|
||||||
|
+ // See opengl_resize()'s comment above.
|
||||||
|
SDL_GL_MakeCurrent(window, context);
|
||||||
|
+#endif
|
||||||
|
set_blend_mode(bm->flags & BMF_TRANS);
|
||||||
|
|
||||||
|
glUseProgram(textureShaderProgram.shaderProgram);
|
||||||
|
@@ -976,6 +1128,14 @@
|
||||||
|
|
||||||
|
glUniform1i(textureShaderProgram.uniMutant, (bm->type == BMT_TLUC8) || (bm->flags == BMF_TLUC8));
|
||||||
|
|
||||||
|
+#ifdef __ANDROID__
|
||||||
|
+ android_imm_begin(tcAttrib, lightAttrib);
|
||||||
|
+ android_imm_vertex(convx(vpl[1]->x), convy(vpl[1]->y), 0.0f, 1.0f, 0.0f, light);
|
||||||
|
+ android_imm_vertex(convx(vpl[0]->x), convy(vpl[0]->y), 0.0f, 0.0f, 0.0f, light);
|
||||||
|
+ android_imm_vertex(convx(vpl[2]->x), convy(vpl[2]->y), 0.0f, 1.0f, 1.0f, light);
|
||||||
|
+ android_imm_vertex(convx(vpl[3]->x), convy(vpl[3]->y), 0.0f, 0.0f, 1.0f, light);
|
||||||
|
+ android_imm_end();
|
||||||
|
+#else
|
||||||
|
glBegin(GL_TRIANGLE_STRIP);
|
||||||
|
glVertexAttrib2f(tcAttrib, 1.0f, 0.0f);
|
||||||
|
glVertexAttrib1f(lightAttrib, light);
|
||||||
|
@@ -990,6 +1150,7 @@
|
||||||
|
glVertexAttrib1f(lightAttrib, light);
|
||||||
|
glVertex3f(convx(vpl[3]->x), convy(vpl[3]->y), 0.0f);
|
||||||
|
glEnd();
|
||||||
|
+#endif
|
||||||
|
|
||||||
|
glUniform1i(textureShaderProgram.uniMutant, false);
|
||||||
|
|
||||||
|
@@ -1007,7 +1168,10 @@
|
||||||
|
return CLIP_ALL;
|
||||||
|
}
|
||||||
|
|
||||||
|
+#ifndef __ANDROID__
|
||||||
|
+ // See opengl_resize()'s comment above.
|
||||||
|
SDL_GL_MakeCurrent(window, context);
|
||||||
|
+#endif
|
||||||
|
|
||||||
|
glUseProgram(colorShaderProgram.shaderProgram);
|
||||||
|
glUniformMatrix4fv(colorShaderProgram.uniView, 1, false, ViewMatrix);
|
||||||
|
@@ -1064,7 +1228,11 @@
|
||||||
|
GLint lightAttrib = colorShaderProgram.lightAttrib;
|
||||||
|
|
||||||
|
long a = 0, b = n_verts - 1;
|
||||||
|
+#ifdef __ANDROID__
|
||||||
|
+ android_imm_begin(-1, lightAttrib);
|
||||||
|
+#else
|
||||||
|
glBegin(GL_TRIANGLE_STRIP);
|
||||||
|
+#endif
|
||||||
|
while (true) {
|
||||||
|
draw_vertex(*(p[a]), -1, lightAttrib);
|
||||||
|
if (a++ == b)
|
||||||
|
@@ -1073,7 +1241,11 @@
|
||||||
|
if (b-- == a)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
+#ifdef __ANDROID__
|
||||||
|
+ android_imm_end();
|
||||||
|
+#else
|
||||||
|
glEnd();
|
||||||
|
+#endif
|
||||||
|
|
||||||
|
return CLIP_NONE;
|
||||||
|
}
|
||||||
|
@@ -1085,7 +1257,10 @@
|
||||||
|
}
|
||||||
|
|
||||||
|
void opengl_begin_stars() {
|
||||||
|
+#ifndef __ANDROID__
|
||||||
|
+ // See opengl_resize()'s comment above.
|
||||||
|
SDL_GL_MakeCurrent(window, context);
|
||||||
|
+#endif
|
||||||
|
|
||||||
|
// Bind the star shader before setting the point size: on Android,
|
||||||
|
// questshock_glPointSize() (see the __ANDROID__ block above) needs
|
||||||
|
@@ -1108,11 +1283,19 @@
|
||||||
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
|
||||||
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
|
||||||
|
|
||||||
|
+#ifdef __ANDROID__
|
||||||
|
+ android_stars_begin(starShaderProgram.lightAttrib);
|
||||||
|
+#else
|
||||||
|
glBegin(GL_POINTS);
|
||||||
|
+#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void opengl_end_stars() {
|
||||||
|
+#ifdef __ANDROID__
|
||||||
|
+ android_stars_end();
|
||||||
|
+#else
|
||||||
|
glEnd();
|
||||||
|
+#endif
|
||||||
|
|
||||||
|
// Turn off the stencil test and blending
|
||||||
|
opengl_set_stencil(0x00);
|
||||||
|
@@ -1120,9 +1303,11 @@
|
||||||
|
}
|
||||||
|
|
||||||
|
int opengl_draw_star(fix star_x, fix star_y, int c, bool anti_alias) {
|
||||||
|
+#ifndef __ANDROID__
|
||||||
|
+ // See opengl_resize()'s comment above.
|
||||||
|
SDL_GL_MakeCurrent(window, context);
|
||||||
|
-
|
||||||
|
GLint lightAttrib = starShaderProgram.lightAttrib;
|
||||||
|
+#endif
|
||||||
|
|
||||||
|
float x = fix_float(star_x);
|
||||||
|
float y = fix_float(star_y);
|
||||||
|
@@ -1142,8 +1327,12 @@
|
||||||
|
int color = (std_color_base + std_color_range - 1 - c);
|
||||||
|
color = (255 * color) / (std_color_range + 1);
|
||||||
|
|
||||||
|
+#ifdef __ANDROID__
|
||||||
|
+ android_stars_vertex(x, y, -0.25f, color / 255.0f);
|
||||||
|
+#else
|
||||||
|
glVertexAttrib1f(lightAttrib, color / 255.0f);
|
||||||
|
glVertex3f(x, y, -0.25f);
|
||||||
|
+#endif
|
||||||
|
|
||||||
|
return CLIP_NONE;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
--- a/src/GameSrc/frsetup.c
|
||||||
|
+++ b/src/GameSrc/frsetup.c
|
||||||
|
@@ -635,7 +635,27 @@
|
||||||
|
uchar old_cam_type;
|
||||||
|
int detail;
|
||||||
|
|
||||||
|
+#ifdef __ANDROID__
|
||||||
|
+ // should_opengl_swap() (unlike use_opengl(), see its own
|
||||||
|
+ // FR_PICKUPM_MASK|FR_HACKCAM_MASK exclusion just below and the
|
||||||
|
+ // matching one used for the STEREO_SUPPORT path in fr_send_view()
|
||||||
|
+ // below) doesn't know which _fr context is currently being prepared -
|
||||||
|
+ // it's a frame-level check, also used by Shock.c's SDLDraw() to pick
|
||||||
|
+ // the frame's overall compositing path. render_hack_cameras() (see
|
||||||
|
+ // render.c) calls fr_rend() - and so this function - a second time per
|
||||||
|
+ // real frame for each active hack-camera monitor, rendering into a
|
||||||
|
+ // private software (FR_DOUBLEB_MASK) canvas rather than the actual
|
||||||
|
+ // GL-backed screen. Without this exclusion, opengl_start_frame() runs
|
||||||
|
+ // again mid-frame for that render even though its own polygon draw
|
||||||
|
+ // calls correctly stay on the software path (use_opengl() already
|
||||||
|
+ // excludes them) - re-entering the OpenXR swapchain/framebuffer setup
|
||||||
|
+ // a second time before the first call's frame has ended. Not known to
|
||||||
|
+ // be exercised on desktop (no OpenXR swapchain there), so this fix is
|
||||||
|
+ // kept Android-only rather than changing should_opengl_swap() itself.
|
||||||
|
+ if (should_opengl_swap() && !(_fr_curflags & (FR_PICKUPM_MASK | FR_HACKCAM_MASK))) {
|
||||||
|
+#else
|
||||||
|
if(should_opengl_swap()) {
|
||||||
|
+#endif
|
||||||
|
opengl_start_frame();
|
||||||
|
}
|
||||||
|
|
||||||
|
@@ -823,7 +843,14 @@
|
||||||
|
|
||||||
|
g3_end_frame();
|
||||||
|
|
||||||
|
+#ifdef __ANDROID__
|
||||||
|
+ // Mirrors the same fix in fr_start_view() above - see its comment.
|
||||||
|
+ // opengl_start_frame()/opengl_end_frame() must stay paired per real
|
||||||
|
+ // GL frame, so this exclusion has to match that one exactly.
|
||||||
|
+ if (should_opengl_swap() && !(_fr_curflags & (FR_PICKUPM_MASK | FR_HACKCAM_MASK))) {
|
||||||
|
+#else
|
||||||
|
if(should_opengl_swap()) {
|
||||||
|
+#endif
|
||||||
|
opengl_end_frame();
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
--- a/src/MacSrc/OpenGL.cc
|
||||||
|
+++ b/src/MacSrc/OpenGL.cc
|
||||||
|
@@ -455,10 +455,30 @@
|
||||||
|
#ifndef __ANDROID__
|
||||||
|
// See opengl_resize()'s comment above.
|
||||||
|
SDL_GL_MakeCurrent(window, context);
|
||||||
|
-#endif
|
||||||
|
|
||||||
|
// Done rendering to the frame buffer, reset back to normal
|
||||||
|
BindFrameBuffer(nullptr);
|
||||||
|
+#else
|
||||||
|
+ // BindFrameBuffer(nullptr) (glBindFramebuffer/glBindRenderbuffer to id
|
||||||
|
+ // 0) means "the real window surface" - on desktop that's SDL's actual
|
||||||
|
+ // GL window, but this app's real presentation target is never that
|
||||||
|
+ // SDLSurface's own EGL surface at all - it's the OpenXR swapchain's
|
||||||
|
+ // per-image FBOs, set up separately in xr_swapchain.c. Whatever
|
||||||
|
+ // framebuffer/renderbuffer 0 actually resolves to for gl4es in this
|
||||||
|
+ // configuration, binding it here reliably left gl4es reporting a
|
||||||
|
+ // persistent GL_INVALID_OPERATION (1282) - confirmed by bisecting
|
||||||
|
+ // through this whole render path with per-call checkpoints, all clean
|
||||||
|
+ // right up until "after opengl_end_frame", the one immediately after
|
||||||
|
+ // this exact call.
|
||||||
|
+ //
|
||||||
|
+ // The bind is also functionally dead on Android regardless of that:
|
||||||
|
+ // by the time anything next draws, Shock.c's SDLDraw() has already
|
||||||
|
+ // run xr_frame_begin() -> xr_swapchain_acquire(), which rebinds to
|
||||||
|
+ // that frame's real swapchain FBO before opengl_swap_and_restore()
|
||||||
|
+ // (the only thing that draws before the next opengl_start_frame()'s
|
||||||
|
+ // own BindFrameBuffer(&backupBuffer)) runs - so skipping it changes
|
||||||
|
+ // nothing about what actually gets rendered where.
|
||||||
|
+#endif
|
||||||
|
palette_dirty = false;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
--- a/src/MacSrc/Shock.c
|
||||||
|
+++ b/src/MacSrc/Shock.c
|
||||||
|
@@ -31,6 +31,7 @@
|
||||||
|
#include <SDL.h>
|
||||||
|
|
||||||
|
#ifdef __ANDROID__
|
||||||
|
+#include "qs_config.h"
|
||||||
|
#include "xr_session.h"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
@@ -109,6 +110,15 @@
|
||||||
|
LoadHotkeyKeybinds();
|
||||||
|
LoadMoveKeybinds();
|
||||||
|
|
||||||
|
+#ifdef __ANDROID__
|
||||||
|
+ // Persistent, human-editable JSON config (questshock.config.json) - see
|
||||||
|
+ // qs_config.h/CONFIG.md. After prefs.txt is loaded (above) so this file
|
||||||
|
+ // can override it, before init_all() below so there's no live-vs-
|
||||||
|
+ // restart distinction to worry about yet - every field just takes full
|
||||||
|
+ // effect as part of this same startup.
|
||||||
|
+ qs_config_load_startup();
|
||||||
|
+#endif
|
||||||
|
+
|
||||||
|
// Process some startup arguments
|
||||||
|
|
||||||
|
bool show_splash = !CheckArgument("-nosplash");
|
||||||
|
--- a/src/Libraries/INPUT/Source/sdl_events.c
|
||||||
|
+++ b/src/Libraries/INPUT/Source/sdl_events.c
|
||||||
|
@@ -29,6 +29,10 @@
|
||||||
|
#include <SDL.h>
|
||||||
|
#include <OpenGL.h>
|
||||||
|
|
||||||
|
+#ifdef __ANDROID__
|
||||||
|
+#include "qs_config.h"
|
||||||
|
+#endif
|
||||||
|
+
|
||||||
|
extern SDL_Window *window;
|
||||||
|
extern SDL_Renderer *renderer;
|
||||||
|
|
||||||
|
@@ -481,6 +485,14 @@
|
||||||
|
SDL_Event ev;
|
||||||
|
|
||||||
|
while (SDL_PollEvent(&ev)) {
|
||||||
|
+#ifdef __ANDROID__
|
||||||
|
+ // Custom event types qs_config_request_save()/_load() enqueue from
|
||||||
|
+ // the "Save Configuration"/"Load Configuration" menu buttons (see
|
||||||
|
+ // qs_config.h) - handled here, on this thread, rather than directly
|
||||||
|
+ // from those JNI calls (which run on Android's UI thread).
|
||||||
|
+ if (qs_config_handle_event(&ev))
|
||||||
|
+ continue;
|
||||||
|
+#endif
|
||||||
|
switch (ev.type) {
|
||||||
|
case SDL_QUIT:
|
||||||
|
// a bit hacky at this place, but this would allow exiting the game via the window's [x] button
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
--- a/src/GameSrc/mainloop.c
|
||||||
|
+++ b/src/GameSrc/mainloop.c
|
||||||
|
@@ -57,6 +57,10 @@
|
||||||
|
#include "tools.h"
|
||||||
|
#include "wrapper.h"
|
||||||
|
|
||||||
|
+#ifdef __ANDROID__
|
||||||
|
+#include "qs_config.h"
|
||||||
|
+#endif
|
||||||
|
+
|
||||||
|
// how is the game doing, anyway, set to true at end of time
|
||||||
|
uchar cit_success = FALSE;
|
||||||
|
|
||||||
|
@@ -103,6 +107,16 @@
|
||||||
|
_last_mode = *cmode;
|
||||||
|
(*exit_modes[_last_mode])();
|
||||||
|
*cmode = _new_mode;
|
||||||
|
+#ifdef __ANDROID__
|
||||||
|
+ // Notifies qs_config.c of every loop-mode transition so it can pick
|
||||||
|
+ // between the "Initial Screen" and "Game Screen" virtual-screen
|
||||||
|
+ // presets - see qs_config.h/CONFIG.md. Called synchronously, directly,
|
||||||
|
+ // on this thread (mainloop()'s own) - unlike the UI-thread-triggered
|
||||||
|
+ // config actions elsewhere in qs_config.c, this doesn't need the
|
||||||
|
+ // cross-thread SDL-event dance since it already originates on the
|
||||||
|
+ // right thread.
|
||||||
|
+ qs_screen_profile_notify_loop_mode(*cmode);
|
||||||
|
+#endif
|
||||||
|
_static_change = 0;
|
||||||
|
if (*cmode >= 0)
|
||||||
|
(*enter_modes[*cmode])();
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
--- a/src/GameSrc/cutsloop.c
|
||||||
|
+++ b/src/GameSrc/cutsloop.c
|
||||||
|
@@ -292,11 +292,38 @@
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
+#ifdef __ANDROID__
|
||||||
|
+// The intro (cutscene 0) ships in one file per language - localized
|
||||||
|
+// voice-over - each holding the same resource id (cutscene_anims[0]), so
|
||||||
|
+// opening the current language's file instead is all it takes. The death
|
||||||
|
+// and ending cutscenes only have one version.
|
||||||
|
+static char *intro_files_by_language[3] =
|
||||||
|
+{
|
||||||
|
+ "res/data/svgaintr.res", // English
|
||||||
|
+ "res/data/svfrintr.res", // French
|
||||||
|
+ "res/data/svgeintr.res" // German
|
||||||
|
+};
|
||||||
|
+
|
||||||
|
+static int open_cutscene_file(int id)
|
||||||
|
+{
|
||||||
|
+ if (id == 0 && which_lang > 0 && which_lang < 3)
|
||||||
|
+ {
|
||||||
|
+ int handle = ResOpenFile(intro_files_by_language[(int)which_lang]);
|
||||||
|
+ if (handle > 0)
|
||||||
|
+ return handle;
|
||||||
|
+ // Not installed for this language - fall back to English below.
|
||||||
|
+ }
|
||||||
|
+ return ResOpenFile(cutscene_files[id]);
|
||||||
|
+}
|
||||||
|
+#else
|
||||||
|
+#define open_cutscene_file(id) ResOpenFile(cutscene_files[id])
|
||||||
|
+#endif
|
||||||
|
+
|
||||||
|
short play_cutscene(int id, bool show_credits)
|
||||||
|
{
|
||||||
|
MacTuneKillCurrentTheme();
|
||||||
|
|
||||||
|
- cutscene_filehandle = ResOpenFile(cutscene_files[id]);
|
||||||
|
+ cutscene_filehandle = open_cutscene_file(id);
|
||||||
|
if (cutscene_filehandle <= 0) {
|
||||||
|
// If we failed to play the cutscene, go to setup / credits.
|
||||||
|
_new_mode = SETUP_LOOP;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
--- a/src/GameSrc/mouselook.c
|
||||||
|
+++ b/src/GameSrc/mouselook.c
|
||||||
|
@@ -121,3 +121,10 @@
|
||||||
|
get_mouselook_vel(&mvelx, &mvely);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
+
|
||||||
|
+#ifdef __ANDROID__
|
||||||
|
+// Whether the player is currently in cyberspace; read by the Android
|
||||||
|
+// controller input code (android/app/src/main/cpp/xr_input.c) to switch
|
||||||
|
+// between the normal and cyberspace action sets.
|
||||||
|
+int qs_in_cyberspace(void) { return global_fullmap != NULL && global_fullmap->cyber; }
|
||||||
|
+#endif
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
--- a/src/GameSrc/movekeys.c
|
||||||
|
+++ b/src/GameSrc/movekeys.c
|
||||||
|
@@ -44,6 +44,12 @@
|
||||||
|
extern bool gKeypadOverride;
|
||||||
|
|
||||||
|
uchar parse_motion_key(ushort code, short *cnum, short *cval);
|
||||||
|
+#ifdef __ANDROID__
|
||||||
|
+// Set every frame by android/app/src/main/cpp/xr_input.c.
|
||||||
|
+float qs_cyber_pitch_scale = 1.0f;
|
||||||
|
+float qs_cyber_bank_scale = 1.0f;
|
||||||
|
+#endif
|
||||||
|
+
|
||||||
|
uchar parse_motion_key_cyber(ushort code, short *cnum, short *cval);
|
||||||
|
void init_motion_polling(void);
|
||||||
|
|
||||||
|
@@ -173,6 +179,20 @@
|
||||||
|
*cnum = -1;
|
||||||
|
*cval = 0;
|
||||||
|
|
||||||
|
+#ifdef __ANDROID__
|
||||||
|
+ // Pitch and bank strength, scaled by the controller thumbstick's
|
||||||
|
+ // deflection (see android/app/src/main/cpp/xr_input.c); 1.0 for a key.
|
||||||
|
+ short pitchMax = (short)(CONTROL_MAX_VAL * qs_cyber_pitch_scale + 0.5f);
|
||||||
|
+ short bankMax = (short)(CONTROL_MAX_VAL * qs_cyber_bank_scale + 0.5f);
|
||||||
|
+ if (pitchMax < 1)
|
||||||
|
+ pitchMax = 1;
|
||||||
|
+ if (bankMax < 1)
|
||||||
|
+ bankMax = 1;
|
||||||
|
+#else
|
||||||
|
+ short pitchMax = CONTROL_MAX_VAL;
|
||||||
|
+ short bankMax = CONTROL_MAX_VAL;
|
||||||
|
+#endif
|
||||||
|
+
|
||||||
|
while (MoveCyberKeybinds[i].code != 255) {
|
||||||
|
if (code == MoveCyberKeybinds[i].code) {
|
||||||
|
move = MoveCyberKeybinds[i].move;
|
||||||
|
@@ -189,22 +209,22 @@
|
||||||
|
|
||||||
|
case M_CLIMB:
|
||||||
|
*cnum = CONTROL_YVEL;
|
||||||
|
- *cval = -CONTROL_MAX_VAL;
|
||||||
|
+ *cval = -pitchMax;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case M_BANKLEFT:
|
||||||
|
*cnum = CONTROL_XYROT;
|
||||||
|
- *cval = -CONTROL_MAX_VAL;
|
||||||
|
+ *cval = -bankMax;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case M_BANKRIGHT:
|
||||||
|
*cnum = CONTROL_XYROT;
|
||||||
|
- *cval = CONTROL_MAX_VAL;
|
||||||
|
+ *cval = bankMax;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case M_DIVE:
|
||||||
|
*cnum = CONTROL_YVEL;
|
||||||
|
- *cval = CONTROL_MAX_VAL;
|
||||||
|
+ *cval = pitchMax;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case M_ROLLRIGHT:
|
||||||
|
@@ -219,38 +239,38 @@
|
||||||
|
|
||||||
|
case M_CLIMBLEFT:
|
||||||
|
*cnum = CONTROL_YVEL;
|
||||||
|
- *cval = -CONTROL_MAX_VAL;
|
||||||
|
+ *cval = -pitchMax;
|
||||||
|
if (abs(poll_controls[*cnum]) < abs(*cval))
|
||||||
|
poll_controls[*cnum] = *cval;
|
||||||
|
*cnum = CONTROL_XYROT;
|
||||||
|
- *cval = -CONTROL_MAX_VAL;
|
||||||
|
+ *cval = -bankMax;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case M_CLIMBRIGHT:
|
||||||
|
*cnum = CONTROL_YVEL;
|
||||||
|
- *cval = -CONTROL_MAX_VAL;
|
||||||
|
+ *cval = -pitchMax;
|
||||||
|
if (abs(poll_controls[*cnum]) < abs(*cval))
|
||||||
|
poll_controls[*cnum] = *cval;
|
||||||
|
*cnum = CONTROL_XYROT;
|
||||||
|
- *cval = CONTROL_MAX_VAL;
|
||||||
|
+ *cval = bankMax;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case M_DIVERIGHT:
|
||||||
|
*cnum = CONTROL_YVEL;
|
||||||
|
- *cval = CONTROL_MAX_VAL;
|
||||||
|
+ *cval = pitchMax;
|
||||||
|
if (abs(poll_controls[*cnum]) < abs(*cval))
|
||||||
|
poll_controls[*cnum] = *cval;
|
||||||
|
*cnum = CONTROL_XYROT;
|
||||||
|
- *cval = CONTROL_MAX_VAL;
|
||||||
|
+ *cval = bankMax;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case M_DIVELEFT:
|
||||||
|
*cnum = CONTROL_YVEL;
|
||||||
|
- *cval = CONTROL_MAX_VAL;
|
||||||
|
+ *cval = pitchMax;
|
||||||
|
if (abs(poll_controls[*cnum]) < abs(*cval))
|
||||||
|
poll_controls[*cnum] = *cval;
|
||||||
|
*cnum = CONTROL_XYROT;
|
||||||
|
- *cval = -CONTROL_MAX_VAL;
|
||||||
|
+ *cval = -bankMax;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// Unhandled movement. What I gonna do?
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
--- a/src/GameSrc/hkeyfunc.c
|
||||||
|
+++ b/src/GameSrc/hkeyfunc.c
|
||||||
|
@@ -1465,3 +1465,71 @@
|
||||||
|
#pragma enable_message(202)
|
||||||
|
|
||||||
|
#endif // NOT_YET
|
||||||
|
+
|
||||||
|
+#ifdef __ANDROID__
|
||||||
|
+// Tops up parts of the player's state; android/app/src/main/cpp/qs_config.c
|
||||||
|
+// calls it once per frame with the "unlimited" cheats that are switched on.
|
||||||
|
+// `what` is a bitmask: 1 health, 2 energy, 4 ammo, 8 items (the same values as
|
||||||
|
+// QS_CHEAT_MASK_* in qs_config.c). Only conventional guns use ammo: beam and
|
||||||
|
+// melee weapons keep heat in the same field, so they are left alone (beams draw
|
||||||
|
+// on energy). Spare ammo clips, grenades and drugs are only refilled for kinds
|
||||||
|
+// the player already holds, so the cheat never creates items.
|
||||||
|
+void qs_cheat_refill(int what) {
|
||||||
|
+ const int kAmmoFloor = 99;
|
||||||
|
+ const int kItemFloor = 99;
|
||||||
|
+ uchar vitalsChanged = FALSE;
|
||||||
|
+ uchar inventoryChanged = FALSE;
|
||||||
|
+
|
||||||
|
+ if (player_struct.dead)
|
||||||
|
+ return;
|
||||||
|
+ if ((what & 1) && player_struct.hit_points < PLAYER_MAX_HP) {
|
||||||
|
+ player_struct.hit_points = PLAYER_MAX_HP;
|
||||||
|
+ vitalsChanged = TRUE;
|
||||||
|
+ }
|
||||||
|
+ if ((what & 1) && player_struct.cspace_hp < PLAYER_MAX_HP) {
|
||||||
|
+ player_struct.cspace_hp = PLAYER_MAX_HP;
|
||||||
|
+ vitalsChanged = TRUE;
|
||||||
|
+ }
|
||||||
|
+ if ((what & 2) && player_struct.energy < 255) {
|
||||||
|
+ player_struct.energy = 255;
|
||||||
|
+ vitalsChanged = TRUE;
|
||||||
|
+ }
|
||||||
|
+ if (vitalsChanged)
|
||||||
|
+ chg_set_flg(VITALS_UPDATE);
|
||||||
|
+
|
||||||
|
+ if (what & 4) {
|
||||||
|
+ for (int i = 0; i < NUM_WEAPON_SLOTS; i++) {
|
||||||
|
+ weapon_slot *slot = &player_struct.weapons[i];
|
||||||
|
+ if (slot->type != EMPTY_WEAPON_SLOT && slot->type <= GUN_SUBCLASS_SPECIAL &&
|
||||||
|
+ slot->ammo < kAmmoFloor) {
|
||||||
|
+ slot->ammo = kAmmoFloor;
|
||||||
|
+ inventoryChanged = TRUE;
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ for (int i = 0; i < NUM_AMMO_TYPES; i++) {
|
||||||
|
+ if (player_struct.cartridges[i] > 0 && player_struct.cartridges[i] < kItemFloor) {
|
||||||
|
+ player_struct.cartridges[i] = kItemFloor;
|
||||||
|
+ inventoryChanged = TRUE;
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ if (what & 8) {
|
||||||
|
+ for (int i = 0; i < NUM_GRENADEZ; i++) {
|
||||||
|
+ if (player_struct.grenades[i] > 0 && player_struct.grenades[i] < kItemFloor) {
|
||||||
|
+ player_struct.grenades[i] = kItemFloor;
|
||||||
|
+ inventoryChanged = TRUE;
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ for (int i = 0; i < NUM_DRUGZ; i++) {
|
||||||
|
+ if (player_struct.drugs[i] > 0 && player_struct.drugs[i] < kItemFloor) {
|
||||||
|
+ player_struct.drugs[i] = kItemFloor;
|
||||||
|
+ inventoryChanged = TRUE;
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ if (inventoryChanged) {
|
||||||
|
+ chg_set_flg(INVENTORY_UPDATE);
|
||||||
|
+ mfd_force_update();
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+#endif
|
||||||
@@ -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");
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Forces a fully fresh native build - works around stageEngine sometimes
|
||||||
|
# wrongly reporting UP-TO-DATE after android/engine-patches/ or
|
||||||
|
# android/gl4es-patches/ changes (see gradle_stage_engine_stale_cache memory
|
||||||
|
# note). Run from anywhere. Needs JAVA_HOME set to a JDK 17+; defaults to
|
||||||
|
# Android Studio's bundled JDK if found.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
if [ -z "${JAVA_HOME:-}" ]; then
|
||||||
|
if [ -d /opt/android-studio/jbr ]; then
|
||||||
|
export JAVA_HOME=/opt/android-studio/jbr
|
||||||
|
else
|
||||||
|
echo "JAVA_HOME not set and no JDK found at /opt/android-studio/jbr - export it yourself" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
./gradlew --stop
|
||||||
|
rm -rf \
|
||||||
|
../build/android-engine \
|
||||||
|
../build/android-gl4es \
|
||||||
|
../build/android-prebuilt \
|
||||||
|
app/.cxx \
|
||||||
|
app/build
|
||||||
|
echo "Done - next Gradle build/Run will rebuild from scratch."
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
#Thu Nov 11 18:20:34 PST 2021
|
#Sat Sep 05 16:12:16 CEST 2026
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip
|
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStorePath=wrapper/dists
|
distributionSha256Sum=544c35d6bd849ae8a5ed0bcea39ba677dc40f49df7d1835561582da2009b961d
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ ARG FLUIDSYNTH_LITE_REF=c539a8d9270ba5a3f7d6e460606483fc2ab1eb61
|
|||||||
# Soundfont used for MIDI music, matching what engine/build_deps.sh itself
|
# Soundfont used for MIDI music, matching what engine/build_deps.sh itself
|
||||||
# fetches (a free substitute for the Windows default GM soundfont).
|
# fetches (a free substitute for the Windows default GM soundfont).
|
||||||
ARG SOUNDFONT_URL=http://rancid.kapsi.fi/windows.sf2
|
ARG SOUNDFONT_URL=http://rancid.kapsi.fi/windows.sf2
|
||||||
|
# GLEW, for the Windows/MinGW cross-build only (engine/CMakeLists.txt's
|
||||||
|
# WIN32 branch - Windows' own opengl32.dll only exposes OpenGL 1.1, so
|
||||||
|
# anything newer needs GLEW's runtime extension loading; Linux instead
|
||||||
|
# gets modern prototypes straight from Mesa's headers, no loader needed -
|
||||||
|
# see engine/src/MacSrc/OpenGL.cc). Matches the version engine/'s own
|
||||||
|
# upstream Windows build script (build_win64.sh) used.
|
||||||
|
ARG GLEW_VERSION=2.1.0
|
||||||
# Gitea/GitHub Actions' JS-based actions (actions/checkout,
|
# Gitea/GitHub Actions' JS-based actions (actions/checkout,
|
||||||
# actions/upload-artifact, ...) need a node binary in the container job's
|
# actions/upload-artifact, ...) need a node binary in the container job's
|
||||||
# PATH - this image is otherwise pure C toolchain, so it isn't pulled in
|
# PATH - this image is otherwise pure C toolchain, so it isn't pulled in
|
||||||
@@ -99,6 +106,15 @@ ENV DEBIAN_FRONTEND=noninteractive
|
|||||||
# openssh-client: the CI workflow's `sftp` publish step.
|
# openssh-client: the CI workflow's `sftp` publish step.
|
||||||
# openjdk-17-jdk-headless: Gradle/AGP's own minimum JDK for the APK build.
|
# openjdk-17-jdk-headless: Gradle/AGP's own minimum JDK for the APK build.
|
||||||
# unzip: extracts the Android cmdline-tools zip below.
|
# unzip: extracts the Android cmdline-tools zip below.
|
||||||
|
# mingw-w64: the x86_64-w64-mingw32-{gcc,g++,windres,ar,...} cross
|
||||||
|
# toolchain for the Windows desktop build (see the "Windows cross-compile"
|
||||||
|
# section below). Ubuntu ships both a win32-thread-model and a
|
||||||
|
# posix-thread-model variant behind update-alternatives; the default
|
||||||
|
# (win32) is fine here since nothing in engine/ uses std::thread.
|
||||||
|
# zip: `make package-win`'s Windows release archive - a CI job's own
|
||||||
|
# `make package` runs inside this image as its container (see
|
||||||
|
# .gitea/workflows/build.yml), so it needs to be baked in here, not just
|
||||||
|
# available on a local dev machine's own host (see README).
|
||||||
#
|
#
|
||||||
# All apt installs deliberately live in this one RUN, first, so editing
|
# All apt installs deliberately live in this one RUN, first, so editing
|
||||||
# anything below it (in particular the Android cross-compile steps, the
|
# anything below it (in particular the Android cross-compile steps, the
|
||||||
@@ -108,10 +124,10 @@ ENV DEBIAN_FRONTEND=noninteractive
|
|||||||
# of packages that didn't change.
|
# of packages that didn't change.
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
build-essential cmake make git curl ca-certificates pkg-config gosu \
|
build-essential cmake make git curl ca-certificates pkg-config gosu \
|
||||||
libgl1-mesa-dev libglx-dev libxext-dev libx11-dev libxrandr-dev \
|
patch libgl1-mesa-dev libglx-dev libxext-dev libx11-dev libxrandr-dev \
|
||||||
libxi-dev libxfixes-dev libxss-dev libxinerama-dev libxcursor-dev \
|
libxi-dev libxfixes-dev libxss-dev libxinerama-dev libxcursor-dev \
|
||||||
libogg-dev libvorbis-dev libasound2-dev openssh-client \
|
libogg-dev libvorbis-dev libasound2-dev openssh-client \
|
||||||
openjdk-17-jdk-headless unzip \
|
openjdk-17-jdk-headless unzip mingw-w64 zip \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /opt/prebuilt
|
WORKDIR /opt/prebuilt
|
||||||
@@ -150,10 +166,96 @@ RUN git clone https://github.com/EtherTyper/fluidsynth-lite.git \
|
|||||||
&& rm -rf .git
|
&& rm -rf .git
|
||||||
|
|
||||||
# General MIDI soundfont for fluidsynth playback - engine/build_deps.sh
|
# General MIDI soundfont for fluidsynth playback - engine/build_deps.sh
|
||||||
# fetches the same file and drops it into engine/res/.
|
# fetches the same file and drops it into engine/res/. Shared by both the
|
||||||
|
# Linux and Windows builds below.
|
||||||
RUN mkdir -p soundfont \
|
RUN mkdir -p soundfont \
|
||||||
&& curl -sSL -o soundfont/default.sf2 "${SOUNDFONT_URL}"
|
&& curl -sSL -o soundfont/default.sf2 "${SOUNDFONT_URL}"
|
||||||
|
|
||||||
|
# CMake toolchain file for the Windows/MinGW cross-build below, reused at
|
||||||
|
# container-run time by build-image/build-engine-win.sh (see
|
||||||
|
# MINGW_TOOLCHAIN_FILE) to cross-compile engine/ itself the same way. GCC's
|
||||||
|
# runtime (libgcc/libstdc++) is linked statically so only the SDL2/
|
||||||
|
# SDL2_mixer/GLEW/fluidsynth-lite DLLs (plus libwinpthread, which isn't
|
||||||
|
# safe to static-link the same way) need shipping alongside systemshock.exe.
|
||||||
|
ENV MINGW_TOOLCHAIN_FILE=/opt/mingw-toolchain.cmake
|
||||||
|
RUN printf '%s\n' \
|
||||||
|
'set(CMAKE_SYSTEM_NAME Windows)' \
|
||||||
|
'set(CMAKE_SYSTEM_PROCESSOR x86_64)' \
|
||||||
|
'set(CMAKE_C_COMPILER x86_64-w64-mingw32-gcc)' \
|
||||||
|
'set(CMAKE_CXX_COMPILER x86_64-w64-mingw32-g++)' \
|
||||||
|
'set(CMAKE_RC_COMPILER x86_64-w64-mingw32-windres)' \
|
||||||
|
'set(CMAKE_FIND_ROOT_PATH /usr/x86_64-w64-mingw32)' \
|
||||||
|
'# LIBRARY/INCLUDE/PACKAGE deliberately left at CMake'"'"'s own' \
|
||||||
|
'# cross-compiling default (BOTH) - engine/CMakeLists.txt'"'"'s' \
|
||||||
|
'# BUNDLED SDL2/SDL2_mixer/FluidSynth find_library() calls point at' \
|
||||||
|
'# build_ext/ (outside this sysroot entirely), which ONLY would' \
|
||||||
|
'# refuse to search.' \
|
||||||
|
'set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)' \
|
||||||
|
'set(CMAKE_EXE_LINKER_FLAGS_INIT "-static-libgcc -static-libstdc++")' \
|
||||||
|
> "${MINGW_TOOLCHAIN_FILE}"
|
||||||
|
|
||||||
|
# SDL2 and SDL2_mixer for Windows: unlike the desktop build above, these
|
||||||
|
# are libsdl.org's own official prebuilt MinGW devel packages (headers +
|
||||||
|
# import libs + DLLs for x86_64-w64-mingw32), not built from source here -
|
||||||
|
# SDL2's autotools setup targets *nix; upstream ships MinGW builds
|
||||||
|
# pre-made instead, same as engine/'s own upstream Windows build script
|
||||||
|
# (build_win64.sh) uses. Same version pins as the Linux build above, so
|
||||||
|
# both desktop builds ship the same SDL2/SDL2_mixer release.
|
||||||
|
RUN curl -sSLO "https://www.libsdl.org/release/SDL2-devel-${SDL2_VERSION}-mingw.tar.gz" \
|
||||||
|
&& tar xf "SDL2-devel-${SDL2_VERSION}-mingw.tar.gz" \
|
||||||
|
&& mkdir -p /opt/prebuilt/win \
|
||||||
|
&& mv "SDL2-${SDL2_VERSION}/x86_64-w64-mingw32" /opt/prebuilt/win/sdl2 \
|
||||||
|
&& rm -rf "SDL2-${SDL2_VERSION}" "SDL2-devel-${SDL2_VERSION}-mingw.tar.gz"
|
||||||
|
|
||||||
|
RUN curl -sSLO "https://www.libsdl.org/projects/SDL_mixer/release/SDL2_mixer-devel-${SDL2_MIXER_VERSION}-mingw.tar.gz" \
|
||||||
|
&& tar xf "SDL2_mixer-devel-${SDL2_MIXER_VERSION}-mingw.tar.gz" --exclude=Xcode \
|
||||||
|
&& mv "SDL2_mixer-${SDL2_MIXER_VERSION}/x86_64-w64-mingw32" /opt/prebuilt/win/sdl2_mixer \
|
||||||
|
&& rm -rf "SDL2_mixer-${SDL2_MIXER_VERSION}" "SDL2_mixer-devel-${SDL2_MIXER_VERSION}-mingw.tar.gz"
|
||||||
|
|
||||||
|
# GLEW for Windows: compiled directly instead of via GLEW's own bundled
|
||||||
|
# cross-compile Makefile configs (config/Makefile.linux-mingw64 et al) -
|
||||||
|
# those pass raw `-soname`/`--out-implib` straight to whatever $(LD) is
|
||||||
|
# set to, which only works if LD is the real `ld` binary, not gcc-as-
|
||||||
|
# linker-driver (and that config also hardcodes a 32-bit `i686-w64-
|
||||||
|
# mingw32` host despite the "64" in its name) - simpler and more robust
|
||||||
|
# to just compile+link GLEW's one source file ourselves. Produces
|
||||||
|
# glew32.dll (to ship alongside systemshock.exe) and libglew32.dll.a (the
|
||||||
|
# MinGW import library engine/CMakeLists.txt's WIN32 branch links
|
||||||
|
# against).
|
||||||
|
RUN curl -sSL -o "glew-${GLEW_VERSION}.tgz" \
|
||||||
|
"https://sourceforge.net/projects/glew/files/glew/${GLEW_VERSION}/glew-${GLEW_VERSION}.tgz/download" \
|
||||||
|
&& tar xf "glew-${GLEW_VERSION}.tgz" \
|
||||||
|
&& cd "glew-${GLEW_VERSION}" \
|
||||||
|
&& mkdir -p /opt/prebuilt/win/glew/include/GL /opt/prebuilt/win/glew/lib \
|
||||||
|
&& x86_64-w64-mingw32-gcc -DGLEW_NO_GLU -O2 -Iinclude -c src/glew.c -o glew.o \
|
||||||
|
&& x86_64-w64-mingw32-gcc -shared \
|
||||||
|
-Wl,--out-implib,/opt/prebuilt/win/glew/lib/libglew32.dll.a \
|
||||||
|
-o /opt/prebuilt/win/glew/lib/glew32.dll \
|
||||||
|
glew.o -lopengl32 -lgdi32 -luser32 -lkernel32 \
|
||||||
|
&& cp include/GL/glew.h include/GL/wglew.h /opt/prebuilt/win/glew/include/GL/ \
|
||||||
|
&& cd .. && rm -rf "glew-${GLEW_VERSION}" "glew-${GLEW_VERSION}.tgz"
|
||||||
|
|
||||||
|
# fluidsynth-lite for Windows: same source/ref/DLL-mode patch as the
|
||||||
|
# desktop build above, cross-compiled via the MinGW toolchain file. WIN32
|
||||||
|
# skips fluidsynth-lite's own pthread dependency (see its CMakeLists.txt),
|
||||||
|
# so no libwinpthread linkage to worry about here.
|
||||||
|
RUN git clone https://github.com/EtherTyper/fluidsynth-lite.git fluidsynth-lite-win \
|
||||||
|
&& cd fluidsynth-lite-win \
|
||||||
|
&& git checkout "${FLUIDSYNTH_LITE_REF}" \
|
||||||
|
&& sed -i 's/DLL"\ off/DLL"\ on/' CMakeLists.txt \
|
||||||
|
&& rm -rf .git \
|
||||||
|
&& cd .. \
|
||||||
|
&& cmake -S fluidsynth-lite-win -B build-fluidsynth-win \
|
||||||
|
-DCMAKE_TOOLCHAIN_FILE="${MINGW_TOOLCHAIN_FILE}" \
|
||||||
|
&& cmake --build build-fluidsynth-win -j"$(nproc)" \
|
||||||
|
&& mkdir -p /opt/prebuilt/win/fluidsynth-lite/lib /opt/prebuilt/win/fluidsynth-lite/include \
|
||||||
|
&& cp build-fluidsynth-win/src/*.dll build-fluidsynth-win/src/*.dll.a \
|
||||||
|
/opt/prebuilt/win/fluidsynth-lite/lib/ \
|
||||||
|
&& cp -a fluidsynth-lite-win/include/. /opt/prebuilt/win/fluidsynth-lite/include/ \
|
||||||
|
&& cp build-fluidsynth-win/include/fluidsynth/version.h \
|
||||||
|
/opt/prebuilt/win/fluidsynth-lite/include/fluidsynth/version.h \
|
||||||
|
&& rm -rf fluidsynth-lite-win build-fluidsynth-win
|
||||||
|
|
||||||
# Node.js: needed only so Gitea/GitHub Actions' JS-based actions can run
|
# Node.js: needed only so Gitea/GitHub Actions' JS-based actions can run
|
||||||
# when this image is used as a CI job's container - see NODE_VERSION above.
|
# when this image is used as a CI job's container - see NODE_VERSION above.
|
||||||
RUN curl -sSL -o /tmp/node.tar.xz \
|
RUN curl -sSL -o /tmp/node.tar.xz \
|
||||||
@@ -283,7 +385,15 @@ RUN git clone --branch "release-${ANDROID_OPENXR_VERSION}" --depth 1 \
|
|||||||
|
|
||||||
COPY build-image/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
COPY build-image/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
COPY build-image/build-engine.sh /usr/local/bin/build-engine.sh
|
COPY build-image/build-engine.sh /usr/local/bin/build-engine.sh
|
||||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh /usr/local/bin/build-engine.sh
|
# 755, not +x: the entrypoint drops to a `builder` user matching the
|
||||||
|
# *host's* UID/GID (see docker-entrypoint.sh), which is never in these
|
||||||
|
# root-owned files' group, so it needs the image's own explicit
|
||||||
|
# world-read+execute here - `chmod +x` alone only adds execute bits on
|
||||||
|
# top of whatever "other" permissions the source file happened to have
|
||||||
|
# (which depends on the host's umask at checkout, e.g. a restrictive
|
||||||
|
# 0007 umask yields unreadable-by-other files, which then round-trip
|
||||||
|
# into the image and cause a Permission denied at container run time).
|
||||||
|
RUN chmod 755 /usr/local/bin/docker-entrypoint.sh /usr/local/bin/build-engine.sh
|
||||||
|
|
||||||
# Marks a shell as already running inside this image (with every engine
|
# Marks a shell as already running inside this image (with every engine
|
||||||
# build dependency prebuilt above) - lets the Makefile's `engine` target
|
# build dependency prebuilt above) - lets the Makefile's `engine` target
|
||||||
|
|||||||
@@ -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 =="
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Cross-compiles engine/ (the vendored Shockolate snapshot) for Windows
|
||||||
|
# (x86_64) via MinGW, against the dependencies prebuilt into this image at
|
||||||
|
# /opt/prebuilt/win - no network access needed. engine/CMakeLists.txt
|
||||||
|
# already has working WIN32/MINGW branches (the upstream Shockolate
|
||||||
|
# project's own build_win64.sh/appveyor.yml build the same way, natively
|
||||||
|
# on Windows via Git Bash/MinGW; this cross-compiles the same branches
|
||||||
|
# fully offline and reproducibly from Linux instead), so building it needs
|
||||||
|
# no patches of its own beyond engine-patches/ (applied below),
|
||||||
|
# unlike the Quest build's android/engine-patches/.
|
||||||
|
#
|
||||||
|
# Builds from a scratch copy of engine/ (like android/engine-patches/
|
||||||
|
# does for the Quest build) rather than engine/ itself: its BUNDLED
|
||||||
|
# dependency paths (build_ext/built_sdl etc.) and in-source CMake cache
|
||||||
|
# are relative to/inside the source tree, and would otherwise collide
|
||||||
|
# with the Linux desktop build's own build_ext/CMakeCache.txt (see
|
||||||
|
# build-engine.sh) if both are built from the same checkout, as `make
|
||||||
|
# dist` (dist-linux + dist-win) does.
|
||||||
|
#
|
||||||
|
# Must be run with the repo root as the working directory - either via
|
||||||
|
# ../run-image.sh, or directly when already inside this image (see the
|
||||||
|
# Makefile's `engine-win` target, which picks whichever of these
|
||||||
|
# applies, same as `engine`).
|
||||||
|
#
|
||||||
|
# Output lands in engine/.build-output-win/: systemshock.exe, the DLLs
|
||||||
|
# it needs at runtime, and a default MIDI soundfont - everything the
|
||||||
|
# root Makefile needs to assemble dist-win/.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(pwd)"
|
||||||
|
SCRATCH_ENGINE="$REPO_ROOT/build/win-engine"
|
||||||
|
OUT_DIR="$REPO_ROOT/engine/.build-output-win"
|
||||||
|
|
||||||
|
echo "== Preparing a scratch copy of engine/ for the Windows cross-build =="
|
||||||
|
rm -rf "$SCRATCH_ENGINE"
|
||||||
|
mkdir -p "$(dirname "$SCRATCH_ENGINE")"
|
||||||
|
|
||||||
|
# See build-engine.sh for why this detection is needed (e.g. a VirtualBox
|
||||||
|
# vboxsf shared folder refusing to create symlinks or preserve hard-link
|
||||||
|
# relationships) - applied here too since build/ is under the same
|
||||||
|
# bind-mounted repo checkout.
|
||||||
|
CP_FLAGS=(-a)
|
||||||
|
mkdir -p "$(dirname "$SCRATCH_ENGINE")/.symlink-test-dir"
|
||||||
|
if ! ln -s test-target "$(dirname "$SCRATCH_ENGINE")/.symlink-test-dir/test" 2>/dev/null; then
|
||||||
|
echo "(destination filesystem doesn't support symlinks - copying real file content instead)"
|
||||||
|
CP_FLAGS=(-a --dereference --no-preserve=links)
|
||||||
|
fi
|
||||||
|
rm -rf "$(dirname "$SCRATCH_ENGINE")/.symlink-test-dir"
|
||||||
|
|
||||||
|
cp "${CP_FLAGS[@]}" "$REPO_ROOT/engine" "$SCRATCH_ENGINE"
|
||||||
|
|
||||||
|
echo "== Applying engine-patches/ =="
|
||||||
|
for p in "$REPO_ROOT"/engine-patches/*.patch; do
|
||||||
|
patch -p1 -d "$SCRATCH_ENGINE" < "$p"
|
||||||
|
done
|
||||||
|
|
||||||
|
cd "$SCRATCH_ENGINE"
|
||||||
|
|
||||||
|
# If engine/ has ever been built in-source directly (`make dist`/`make
|
||||||
|
# engine` - see build-engine.sh), that leftover CMakeCache.txt etc. just
|
||||||
|
# got copied along verbatim - and it still points at the real engine/
|
||||||
|
# path, not this scratch copy, which CMake refuses to configure against
|
||||||
|
# ("directory is different than the directory where CMakeCache.txt was
|
||||||
|
# created"). Same artifact list as .gitignore's "Engine build artifacts"
|
||||||
|
# section.
|
||||||
|
rm -rf build_ext CMakeCache.txt CMakeFiles cmake_install.cmake Makefile \
|
||||||
|
systemshock src/Libraries/CMakeFiles
|
||||||
|
|
||||||
|
echo "== Wiring up prebuilt SDL2/SDL2_mixer/GLEW/fluidsynth-lite (Windows/MinGW) from the image =="
|
||||||
|
rm -rf build_ext
|
||||||
|
mkdir -p build_ext
|
||||||
|
cp "${CP_FLAGS[@]}" /opt/prebuilt/win/sdl2 build_ext/built_sdl
|
||||||
|
cp "${CP_FLAGS[@]}" /opt/prebuilt/win/sdl2_mixer build_ext/built_sdl_mixer
|
||||||
|
cp "${CP_FLAGS[@]}" /opt/prebuilt/win/glew build_ext/built_glew
|
||||||
|
# engine/CMakeLists.txt's BUNDLED FluidSynth mode hardcodes
|
||||||
|
# build_ext/fluidsynth-lite/src as the library search path (matching the
|
||||||
|
# desktop Linux in-source build's own output layout) - same lib/ -> src/
|
||||||
|
# remapping build-image/prepare-android-project.sh does for the Quest
|
||||||
|
# build's own prebuilt fluidsynth-lite.
|
||||||
|
mkdir -p build_ext/fluidsynth-lite/src build_ext/fluidsynth-lite/include
|
||||||
|
cp "${CP_FLAGS[@]}" /opt/prebuilt/win/fluidsynth-lite/lib/. build_ext/fluidsynth-lite/src/
|
||||||
|
cp "${CP_FLAGS[@]}" /opt/prebuilt/win/fluidsynth-lite/include/. build_ext/fluidsynth-lite/include/
|
||||||
|
|
||||||
|
echo "== Configuring (CMake, MinGW cross-compile, BUNDLED SDL2/SDL2_mixer/FluidSynth) =="
|
||||||
|
cmake -DCMAKE_TOOLCHAIN_FILE="$MINGW_TOOLCHAIN_FILE" \
|
||||||
|
-DENABLE_SDL2=BUNDLED -DENABLE_SOUND=BUNDLED -DENABLE_FLUIDSYNTH=BUNDLED .
|
||||||
|
|
||||||
|
echo "== Compiling =="
|
||||||
|
make -j"$(nproc)" systemshock
|
||||||
|
|
||||||
|
echo "== Assembling engine/.build-output-win =="
|
||||||
|
rm -rf "$OUT_DIR"
|
||||||
|
mkdir -p "$OUT_DIR"
|
||||||
|
cp systemshock.exe "$OUT_DIR/"
|
||||||
|
# Only SDL2.dll/SDL2_mixer.dll themselves - not SDL2_mixer's own bundled
|
||||||
|
# codec DLLs (libvorbis, libmodplug, libopus, ...), matching what
|
||||||
|
# engine/'s own upstream Windows build script (build_win64.sh) ships:
|
||||||
|
# Shockolate only ever calls Mix_LoadWAV_RW/Mix_HookMusic (same as the
|
||||||
|
# Quest build - see build-image/Dockerfile's Android layer comment),
|
||||||
|
# never loading the OGG/MOD/MP3 game data those codecs would be for.
|
||||||
|
cp build_ext/built_sdl/bin/SDL2.dll build_ext/built_sdl_mixer/bin/SDL2_mixer.dll "$OUT_DIR/"
|
||||||
|
cp build_ext/built_glew/lib/glew32.dll "$OUT_DIR/"
|
||||||
|
cp build_ext/fluidsynth-lite/src/*.dll "$OUT_DIR/"
|
||||||
|
# libgcc/libstdc++ are statically linked (see the Dockerfile's
|
||||||
|
# MINGW_TOOLCHAIN_FILE), but fluidsynth-lite/SDL2 still pull in the
|
||||||
|
# MinGW pthread emulation dynamically.
|
||||||
|
cp /usr/x86_64-w64-mingw32/lib/libwinpthread-1.dll "$OUT_DIR/"
|
||||||
|
cp /opt/prebuilt/soundfont/default.sf2 "$OUT_DIR/soundfont.sf2"
|
||||||
|
|
||||||
|
echo "== Done =="
|
||||||
|
echo "Binary: $OUT_DIR/systemshock.exe"
|
||||||
|
echo "DLLs: $OUT_DIR/*.dll"
|
||||||
|
echo "Soundfont: $OUT_DIR/soundfont.sf2"
|
||||||
@@ -8,23 +8,67 @@
|
|||||||
# container - see the Makefile's `engine` target, which picks whichever
|
# container - see the Makefile's `engine` target, which picks whichever
|
||||||
# of these applies).
|
# of these applies).
|
||||||
#
|
#
|
||||||
|
# Builds from a scratch copy of engine/, patched with engine-patches/
|
||||||
|
# (see that directory and README.md section 4.4) - same reasoning as
|
||||||
|
# build-engine-win.sh's own scratch copy: engine/ itself is never modified in
|
||||||
|
# place (README.md section 1).
|
||||||
|
#
|
||||||
# Output lands in engine/.build-output/: the systemshock binary, the
|
# Output lands in engine/.build-output/: the systemshock binary, the
|
||||||
# shared libraries it needs at runtime (lib/), and a default MIDI
|
# shared libraries it needs at runtime (lib/), and a default MIDI
|
||||||
# soundfont - everything the root Makefile needs to assemble dist/.
|
# soundfont - everything the root Makefile needs to assemble dist/.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
REPO_ROOT="$(pwd)"
|
REPO_ROOT="$(pwd)"
|
||||||
ENGINE_DIR="$REPO_ROOT/engine"
|
SCRATCH_ENGINE="$REPO_ROOT/build/linux-engine"
|
||||||
OUT_DIR="$ENGINE_DIR/.build-output"
|
OUT_DIR="$REPO_ROOT/engine/.build-output"
|
||||||
|
|
||||||
cd "$ENGINE_DIR"
|
echo "== Preparing a scratch copy of engine/, patched with engine-patches/ =="
|
||||||
|
rm -rf "$SCRATCH_ENGINE"
|
||||||
|
mkdir -p "$(dirname "$SCRATCH_ENGINE")"
|
||||||
|
cp -a "$REPO_ROOT/engine" "$SCRATCH_ENGINE"
|
||||||
|
|
||||||
|
# A checkout built in place by an older version of this script (before it
|
||||||
|
# staged a scratch copy) can still have build_ext/CMakeCache.txt etc.
|
||||||
|
# sitting in the real engine/ - copied along above, they'd make CMake
|
||||||
|
# refuse to configure this copy ("directory is different than the
|
||||||
|
# directory where CMakeCache.txt was created"). Same cleanup
|
||||||
|
# build-engine-win.sh's own scratch copy does.
|
||||||
|
rm -rf "$SCRATCH_ENGINE"/build_ext "$SCRATCH_ENGINE"/CMakeCache.txt \
|
||||||
|
"$SCRATCH_ENGINE"/CMakeFiles "$SCRATCH_ENGINE"/cmake_install.cmake \
|
||||||
|
"$SCRATCH_ENGINE"/Makefile "$SCRATCH_ENGINE"/systemshock \
|
||||||
|
"$SCRATCH_ENGINE"/src/Libraries/CMakeFiles
|
||||||
|
|
||||||
|
for p in "$REPO_ROOT"/engine-patches/*.patch; do
|
||||||
|
patch -p1 -d "$SCRATCH_ENGINE" < "$p"
|
||||||
|
done
|
||||||
|
|
||||||
|
cd "$SCRATCH_ENGINE"
|
||||||
|
|
||||||
echo "== Wiring up prebuilt SDL2/SDL2_mixer/fluidsynth-lite from the image =="
|
echo "== Wiring up prebuilt SDL2/SDL2_mixer/fluidsynth-lite from the image =="
|
||||||
rm -rf build_ext
|
rm -rf build_ext
|
||||||
mkdir -p build_ext
|
mkdir -p build_ext
|
||||||
cp -a /opt/prebuilt/built_sdl build_ext/
|
|
||||||
cp -a /opt/prebuilt/built_sdl_mixer build_ext/
|
# Some filesystems the repo might be checked out on (e.g. a VirtualBox
|
||||||
cp -a /opt/prebuilt/fluidsynth-lite build_ext/
|
# vboxsf shared folder) refuse to create symlinks at all ("Operation not
|
||||||
|
# permitted"), which plain `cp -a` needs for SDL2/SDL2_mixer's
|
||||||
|
# libFoo.so -> libFoo.so.N -> libFoo.so.N.M dev-symlink chain. Detect
|
||||||
|
# that up front, once, and copy real file content instead of recreating
|
||||||
|
# links if so, rather than failing partway through. `-a` implies
|
||||||
|
# --preserve=all (which includes hard-link relationships between files,
|
||||||
|
# not just symlinks - SDL2's install hard-links the two most-specific
|
||||||
|
# version files together), so --no-preserve=links is needed alongside
|
||||||
|
# --dereference to avoid that too.
|
||||||
|
CP_FLAGS=(-a)
|
||||||
|
if ! ln -s test-target build_ext/.symlink-test 2>/dev/null; then
|
||||||
|
echo "(destination filesystem doesn't support symlinks - copying real file content instead)"
|
||||||
|
CP_FLAGS=(-a --dereference --no-preserve=links)
|
||||||
|
else
|
||||||
|
rm -f build_ext/.symlink-test
|
||||||
|
fi
|
||||||
|
|
||||||
|
cp "${CP_FLAGS[@]}" /opt/prebuilt/built_sdl build_ext/
|
||||||
|
cp "${CP_FLAGS[@]}" /opt/prebuilt/built_sdl_mixer build_ext/
|
||||||
|
cp "${CP_FLAGS[@]}" /opt/prebuilt/fluidsynth-lite build_ext/
|
||||||
|
|
||||||
echo "== Configuring (CMake, BUNDLED SDL2/SDL2_mixer/FluidSynth) =="
|
echo "== Configuring (CMake, BUNDLED SDL2/SDL2_mixer/FluidSynth) =="
|
||||||
rm -f CMakeCache.txt
|
rm -f CMakeCache.txt
|
||||||
@@ -38,7 +82,7 @@ rm -rf "$OUT_DIR"
|
|||||||
mkdir -p "$OUT_DIR/lib"
|
mkdir -p "$OUT_DIR/lib"
|
||||||
cp systemshock "$OUT_DIR/"
|
cp systemshock "$OUT_DIR/"
|
||||||
find build_ext/built_sdl/lib build_ext/built_sdl_mixer/lib build_ext/fluidsynth-lite/src \
|
find build_ext/built_sdl/lib build_ext/built_sdl_mixer/lib build_ext/fluidsynth-lite/src \
|
||||||
-name '*.so*' -not -name '*.la' -exec cp -a {} "$OUT_DIR/lib/" \;
|
-name '*.so*' -not -name '*.la' -exec cp "${CP_FLAGS[@]}" {} "$OUT_DIR/lib/" \;
|
||||||
cp /opt/prebuilt/soundfont/default.sf2 "$OUT_DIR/soundfont.sf2"
|
cp /opt/prebuilt/soundfont/default.sf2 "$OUT_DIR/soundfont.sf2"
|
||||||
|
|
||||||
echo "== Done =="
|
echo "== Done =="
|
||||||
|
|||||||
@@ -11,4 +11,17 @@ USER_GID="${HOST_GID:-1000}"
|
|||||||
groupadd -g "$USER_GID" builder 2>/dev/null || true
|
groupadd -g "$USER_GID" builder 2>/dev/null || true
|
||||||
useradd -u "$USER_UID" -g "$USER_GID" -m -s /bin/bash builder 2>/dev/null || true
|
useradd -u "$USER_UID" -g "$USER_GID" -m -s /bin/bash builder 2>/dev/null || true
|
||||||
|
|
||||||
|
# /workspace (the bind-mounted repo) may actually be owned by a group the
|
||||||
|
# host user only has via *supplementary* membership rather than their
|
||||||
|
# primary GID above - e.g. a VirtualBox vboxsf shared folder, which shows
|
||||||
|
# up as root:vboxsf on the host and would otherwise be inaccessible to
|
||||||
|
# `builder` here (falls through to "other", which vboxsf's default mode
|
||||||
|
# leaves with no permissions at all). Join whatever group actually owns
|
||||||
|
# /workspace too, if it differs.
|
||||||
|
WORKSPACE_GID="$(stat -c %g /workspace 2>/dev/null || true)"
|
||||||
|
if [ -n "$WORKSPACE_GID" ] && [ "$WORKSPACE_GID" != "$USER_GID" ]; then
|
||||||
|
groupadd -g "$WORKSPACE_GID" workspace 2>/dev/null || true
|
||||||
|
usermod -aG "$WORKSPACE_GID" builder 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
exec gosu builder "$@"
|
exec gosu builder "$@"
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ fi
|
|||||||
echo "prebuiltDir=$PREBUILT_DIR_PROP"
|
echo "prebuiltDir=$PREBUILT_DIR_PROP"
|
||||||
} > "$ANDROID_DIR/engine.properties"
|
} > "$ANDROID_DIR/engine.properties"
|
||||||
|
|
||||||
echo "== Staging bundled assets (shaders, soundfont, get-assets text) =="
|
echo "== Staging bundled assets (shaders, soundfont, get-assets text, default config) =="
|
||||||
ASSETS_DIR="$ANDROID_DIR/app/src/main/assets"
|
ASSETS_DIR="$ANDROID_DIR/app/src/main/assets"
|
||||||
rm -rf "$ASSETS_DIR"
|
rm -rf "$ASSETS_DIR"
|
||||||
mkdir -p "$ASSETS_DIR/shaders" "$ASSETS_DIR/res"
|
mkdir -p "$ASSETS_DIR/shaders" "$ASSETS_DIR/res"
|
||||||
@@ -91,6 +91,9 @@ mkdir -p "$ASSETS_DIR/shaders" "$ASSETS_DIR/res"
|
|||||||
cp -a "$ANDROID_DIR/gles-shaders/." "$ASSETS_DIR/shaders/"
|
cp -a "$ANDROID_DIR/gles-shaders/." "$ASSETS_DIR/shaders/"
|
||||||
cp "/opt/prebuilt/soundfont/default.sf2" "$ASSETS_DIR/res/soundfont.sf2"
|
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 -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
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
--- a/src/MacSrc/OpenGL.cc 2026-07-19 13:11:51.000000000 +0200
|
||||||
|
+++ b/src/MacSrc/OpenGL.cc 2026-09-27 11:42:22.497868697 +0200
|
||||||
|
@@ -562,7 +562,14 @@
|
||||||
|
static bool opengl_cache_texture(CachedTexture toCache, grs_bitmap *bm) {
|
||||||
|
SDL_GL_MakeCurrent(window, context);
|
||||||
|
|
||||||
|
- glPixelStorei(GL_UNPACK_ROW_LENGTH, bm->row);
|
||||||
|
+ // GL_UNPACK_ROW_LENGTH must describe the buffer actually passed to
|
||||||
|
+ // glTexImage2D() below (toCache.converted->pixels, a separately
|
||||||
|
+ // allocated SDL_PIXELFORMAT_RGBA32 surface from
|
||||||
|
+ // SDL_ConvertSurfaceFormat()) - not bm->row, the original 8-bit source
|
||||||
|
+ // bitmap's own unrelated row stride. Using bm->row here describes the
|
||||||
|
+ // wrong stride to OpenGL whenever the two differ, corrupting the
|
||||||
|
+ // texture upload.
|
||||||
|
+ glPixelStorei(GL_UNPACK_ROW_LENGTH, toCache.converted->pitch / 4);
|
||||||
|
|
||||||
|
if (texturesByBitsPtr.size() < MAX_CACHED_TEXTURES) {
|
||||||
|
// We have enough room, generate the new texture
|
||||||
|
@@ -696,7 +703,10 @@
|
||||||
|
t->lastDrawTime = *tmd_ticks;
|
||||||
|
|
||||||
|
if (isDirty) {
|
||||||
|
- glPixelStorei(GL_UNPACK_ROW_LENGTH, bm->row);
|
||||||
|
+ // Same fix as opengl_cache_texture() above - t->converted->pixels is
|
||||||
|
+ // what's actually uploaded, so its own pitch is what
|
||||||
|
+ // GL_UNPACK_ROW_LENGTH needs to describe, not bm->row.
|
||||||
|
+ glPixelStorei(GL_UNPACK_ROW_LENGTH, t->converted->pitch / 4);
|
||||||
|
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, bm->w, bm->h, 0, GL_RGBA, GL_UNSIGNED_BYTE, t->converted->pixels);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
--- a/src/MacSrc/SDLSound.c 2026-07-19 13:11:51.000000000 +0200
|
||||||
|
+++ b/src/MacSrc/SDLSound.c 2026-09-27 11:42:37.969726579 +0200
|
||||||
|
@@ -42,7 +42,7 @@
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Mix_OpenAudio(48000, AUDIO_S16SYS, 2, 2048) < 0) {
|
||||||
|
- ERROR("%s: Couldn't open audio device", __FUNCTION__);
|
||||||
|
+ ERROR("%s: Couldn't open audio device: %s", __FUNCTION__, SDL_GetError());
|
||||||
|
}
|
||||||
|
|
||||||
|
Mix_AllocateChannels(SND_MAX_SAMPLES);
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
--- a/src/MacSrc/Modding.c 2026-07-19 13:11:51.000000000 +0200
|
||||||
|
+++ b/src/MacSrc/Modding.c 2026-09-27 19:44:24.718059783 +0200
|
||||||
|
@@ -27,6 +27,11 @@
|
||||||
|
char *modding_additional_files[MAX_MOD_FILES];
|
||||||
|
int num_mod_files;
|
||||||
|
|
||||||
|
+// One filenum per modding_additional_files[] entry, or -1 if not open yet -
|
||||||
|
+// lets LoadModFiles() close a mod file's previous handle before reopening it
|
||||||
|
+// (see LoadModFiles() below for why it needs to be called more than once).
|
||||||
|
+static int32_t modding_loaded_filenum[MAX_MOD_FILES];
|
||||||
|
+
|
||||||
|
int StringEndsWith(char *src, char *dst) {
|
||||||
|
char *s = strrchr(src, '.');
|
||||||
|
|
||||||
|
@@ -86,6 +91,7 @@
|
||||||
|
modding_archive_override = NULL;
|
||||||
|
for (int i = 0; i < MAX_MOD_FILES; i++) {
|
||||||
|
modding_additional_files[i] = NULL;
|
||||||
|
+ modding_loaded_filenum[i] = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now go process args
|
||||||
|
@@ -135,10 +141,24 @@
|
||||||
|
}
|
||||||
|
|
||||||
|
int LoadModFiles() {
|
||||||
|
+ // Called again by obj_set_secondary_properties() (objsim.c) after it
|
||||||
|
+ // re-opens the base game's own object art files, which would otherwise
|
||||||
|
+ // silently reclaim any resource ID a mod file already overrode here (see
|
||||||
|
+ // ResReadDirEntries() in resfile.c - opening a file re-registers every
|
||||||
|
+ // resource ID it contains, last one in wins, with no check for whether
|
||||||
|
+ // an ID is already claimed). Re-registering means actually re-opening
|
||||||
|
+ // (there is no "just re-register" primitive), so a mod file already open
|
||||||
|
+ // from an earlier call is closed first - one open handle per mod file at
|
||||||
|
+ // a time, rather than leaking a fresh resFile[] slot on every repeat call
|
||||||
|
+ // (new game, load game - object_data_load() can run more than once per
|
||||||
|
+ // process).
|
||||||
|
for (int i = 0; i < num_mod_files; i++) {
|
||||||
|
if (modding_additional_files[i] != NULL) {
|
||||||
|
+ if (modding_loaded_filenum[i] >= 0) {
|
||||||
|
+ ResCloseFile(modding_loaded_filenum[i]);
|
||||||
|
+ }
|
||||||
|
printf("Loading mod file %s\n", modding_additional_files[i]);
|
||||||
|
- ResOpenFile(modding_additional_files[i]);
|
||||||
|
+ modding_loaded_filenum[i] = ResOpenFile(modding_additional_files[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
--- a/src/GameSrc/objsim.c 2026-07-19 13:11:51.000000000 +0200
|
||||||
|
+++ b/src/GameSrc/objsim.c 2026-09-27 19:44:17.014441306 +0200
|
||||||
|
@@ -43,6 +43,7 @@
|
||||||
|
#include "gettmaps.h"
|
||||||
|
#include "lvldata.h"
|
||||||
|
#include "mapflags.h"
|
||||||
|
+#include "Modding.h"
|
||||||
|
#include "objart2.h"
|
||||||
|
#include "objart3.h"
|
||||||
|
#include "objects.h"
|
||||||
|
@@ -1877,6 +1878,13 @@
|
||||||
|
// Warning(("Problem opening object art cache!\n"));
|
||||||
|
return (ERR_NOEFFECT);
|
||||||
|
}
|
||||||
|
+ // The two opens above re-register every resource ID objart2.res/
|
||||||
|
+ // objart3.res contain, reclaiming any of them a mod file already
|
||||||
|
+ // overrode (see LoadModFiles() in Modding.c) - restore mod priority
|
||||||
|
+ // for the reftable reads below. load_dynamic_memory() (cybmem.c) reopens
|
||||||
|
+ // these same two files again later, persistently, for actual rendering -
|
||||||
|
+ // restoring priority there too.
|
||||||
|
+ LoadModFiles();
|
||||||
|
|
||||||
|
// Now go and set specific computed data, like frame counts
|
||||||
|
for (i = 0; i < NUM_CRITTER; i++) {
|
||||||
|
--- a/src/GameSrc/cybmem.c 2026-07-19 13:11:51.000000000 +0200
|
||||||
|
+++ b/src/GameSrc/cybmem.c 2026-09-27 19:44:24.718059783 +0200
|
||||||
|
@@ -33,6 +33,7 @@
|
||||||
|
#include "textmaps.h"
|
||||||
|
#include "objcrit.h"
|
||||||
|
#include "dynmem.h"
|
||||||
|
+#include "Modding.h"
|
||||||
|
#include "Shock.h"
|
||||||
|
#include "sideicon.h"
|
||||||
|
#include "criterr.h"
|
||||||
|
@@ -120,6 +121,15 @@
|
||||||
|
critical_error(CRITERR_RES | 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
+ // The four opens above re-register every resource ID their files
|
||||||
|
+ // contain, reclaiming any of them a mod file already overrode (see
|
||||||
|
+ // LoadModFiles() in Modding.c) - these are the persistent handles
|
||||||
|
+ // actual gameplay renders from (unlike obj_set_secondary_properties()'s
|
||||||
|
+ // own transient open/close of objart2.res/objart3.res for reading
|
||||||
|
+ // frame-count metadata), so this is the last point mod priority needs
|
||||||
|
+ // restoring before rendering.
|
||||||
|
+ LoadModFiles();
|
||||||
|
+
|
||||||
|
loadcount |= mask;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
--- a/src/GameSrc/objsim.c 2026-09-27 19:44:17.014441306 +0200
|
||||||
|
+++ b/src/GameSrc/objsim.c 2026-09-27 20:44:23.147083066 +0200
|
||||||
|
@@ -1867,6 +1867,40 @@
|
||||||
|
return (OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
+// Every critter view's base-game bitmap size, keyed by resource ID - filled
|
||||||
|
+// in by cache_critter_native_size() below while objart2.res/objart3.res's
|
||||||
|
+// base-game data is still the active resolver for these IDs (before
|
||||||
|
+// LoadModFiles() hands priority to a mod again), read back by
|
||||||
|
+// get_critter_native_size() in gameobj.c. A mod that replaces a critter's
|
||||||
|
+// art with a different-resolution bitmap changes how much detail is visible,
|
||||||
|
+// not the object's on-screen size: g3_bitmap_common() (Bitmap.c) uses this
|
||||||
|
+// cached size instead of the (possibly modded, possibly larger or smaller)
|
||||||
|
+// bitmap's own width/height when computing the sprite's world-space extent.
|
||||||
|
+// Id is uint16_t, so a flat array indexed by it needs no bounds check.
|
||||||
|
+static short s_critterNativeW[65536];
|
||||||
|
+static short s_critterNativeH[65536];
|
||||||
|
+
|
||||||
|
+static void cache_critter_native_size(Id id) {
|
||||||
|
+ FrameDesc *f;
|
||||||
|
+ if (s_critterNativeW[id] != 0)
|
||||||
|
+ return;
|
||||||
|
+ f = (FrameDesc *)RefLock(MKREF(id, 0));
|
||||||
|
+ if (f == NULL)
|
||||||
|
+ return;
|
||||||
|
+ s_critterNativeW[id] = f->bm.w;
|
||||||
|
+ s_critterNativeH[id] = f->bm.h;
|
||||||
|
+ RefUnlock(MKREF(id, 0));
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+// w/h are left untouched (so a caller can default to 0, meaning "no cached
|
||||||
|
+// native size, use the bitmap's own dimensions") if id was never cached.
|
||||||
|
+void get_critter_native_size(Id id, short *w, short *h) {
|
||||||
|
+ if (s_critterNativeW[id] == 0)
|
||||||
|
+ return;
|
||||||
|
+ *w = s_critterNativeW[id];
|
||||||
|
+ *h = s_critterNativeH[id];
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
errtype obj_set_secondary_properties() {
|
||||||
|
char i, j;
|
||||||
|
RefTable *prt;
|
||||||
|
@@ -1878,6 +1912,28 @@
|
||||||
|
// Warning(("Problem opening object art cache!\n"));
|
||||||
|
return (ERR_NOEFFECT);
|
||||||
|
}
|
||||||
|
+
|
||||||
|
+ // Snapshot every critter view's base-game bitmap size while it's still
|
||||||
|
+ // the active resolver for these IDs, before LoadModFiles() below hands
|
||||||
|
+ // priority back to a mod - see cache_critter_native_size() and its own
|
||||||
|
+ // comment. Full 8-view postures need every view individually (their IDs
|
||||||
|
+ // are 8 consecutive resource IDs, one per view); front-only postures are
|
||||||
|
+ // a single ID (matching the frame-count loop below).
|
||||||
|
+ for (i = 0; i < NUM_CRITTER; i++) {
|
||||||
|
+ if (CritterProps[i].views > 1) {
|
||||||
|
+ for (j = 0; j < NUM_CRITTER_POSTURES; j++) {
|
||||||
|
+ if (j >= FIRST_FRONT_POSTURE) {
|
||||||
|
+ cache_critter_native_size(posture_bases[j] + i);
|
||||||
|
+ } else {
|
||||||
|
+ int v;
|
||||||
|
+ for (v = 0; v < 8; v++) {
|
||||||
|
+ cache_critter_native_size(critter_id_table[i] + posture_bases[j] + v);
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
// The two opens above re-register every resource ID objart2.res/
|
||||||
|
// objart3.res contain, reclaiming any of them a mod file already
|
||||||
|
// overrode (see LoadModFiles() in Modding.c) - restore mod priority
|
||||||
|
--- a/src/GameSrc/Headers/objsim.h 2026-07-19 13:11:51.000000000 +0200
|
||||||
|
+++ b/src/GameSrc/Headers/objsim.h 2026-09-27 19:39:39.585009491 +0200
|
||||||
|
@@ -108,6 +108,7 @@
|
||||||
|
errtype obj_load_properties();
|
||||||
|
errtype obj_create_player(ObjLoc *plr_loc);
|
||||||
|
Ref ref_from_critter_data(ObjID oid, int triple, byte posture, short frame, short view);
|
||||||
|
+void get_critter_native_size(Id id, short *w, short *h);
|
||||||
|
errtype obj_zero_unused();
|
||||||
|
grs_bitmap *obj_get_model_data(ObjID id, fix *x, fix *y, fix *z, grs_bitmap *bm2, Ref *ref1, Ref *ref2);
|
||||||
|
errtype obj_model_hack(ObjID id, uchar *hack_x, uchar *hack_y, uchar *hack_z, uchar *hack_type);
|
||||||
|
--- a/src/GameSrc/gameobj.c 2026-07-19 13:11:51.000000000 +0200
|
||||||
|
+++ b/src/GameSrc/gameobj.c 2026-09-27 20:44:35.195019539 +0200
|
||||||
|
@@ -104,7 +104,7 @@
|
||||||
|
void draw_ice(void);
|
||||||
|
void draw_ice_wall(void);
|
||||||
|
void _fr_draw_tmtile(grs_bitmap *draw_bm, int col_val, g3s_phandle *plst, uchar dblface, uchar use_lighting);
|
||||||
|
-void _fr_draw_bitmap(grs_bitmap *draw_bm, int dist, int sc, int anch_x, int anch_y);
|
||||||
|
+void _fr_draw_bitmap(grs_bitmap *draw_bm, int dist, int sc, int anch_x, int anch_y, short native_w, short native_h);
|
||||||
|
|
||||||
|
int munge_val(int val, int range, int delta) {
|
||||||
|
int base = range - delta;
|
||||||
|
@@ -575,7 +575,7 @@
|
||||||
|
|
||||||
|
// note this always has show_obj's p for p and _fdt_dist for dist.. perhaps shouldnt pass them
|
||||||
|
//#pragma disable_message(202)
|
||||||
|
-void _fr_draw_bitmap(grs_bitmap *draw_bm, int dist, int sc, int anch_x, int anch_y) {
|
||||||
|
+void _fr_draw_bitmap(grs_bitmap *draw_bm, int dist, int sc, int anch_x, int anch_y, short native_w, short native_h) {
|
||||||
|
#ifdef SMOOTH_BITMAPS
|
||||||
|
grs_canvas tmp_can;
|
||||||
|
grs_bitmap tmp_bm;
|
||||||
|
@@ -597,7 +597,7 @@
|
||||||
|
if ((anch_x <= 0) && (anch_y <= 0))
|
||||||
|
bitmap_verts = g3_light_bitmap(draw_bm, anchor);
|
||||||
|
else
|
||||||
|
- bitmap_verts = g3_light_anchor_bitmap(draw_bm, anchor, anch_x, anch_y);
|
||||||
|
+ bitmap_verts = g3_light_anchor_bitmap(draw_bm, anchor, anch_x, anch_y, native_w, native_h);
|
||||||
|
if (sc)
|
||||||
|
g3_set_bitmap_scale(fix_make(0, (int)(2048 / 3)), fix_make(0, (int)(2048 / 3)));
|
||||||
|
|
||||||
|
@@ -855,7 +855,7 @@
|
||||||
|
if ((anchors_3d[o3drep].x > 0) || (anchors_3d[o3drep].y > 0))
|
||||||
|
_o_rad = 0;
|
||||||
|
_fr_draw_bitmap(bitmaps_3d[o3drep], _fdt_dist, ObjProps[objtrip].flags & MY_IM_LARGE, anchors_3d[o3drep].x,
|
||||||
|
- anchors_3d[o3drep].y);
|
||||||
|
+ anchors_3d[o3drep].y, 0, 0);
|
||||||
|
if (global_fullmap->cyber)
|
||||||
|
if (obj_ICE_ICE_BABY(_fr_cobj))
|
||||||
|
draw_ice();
|
||||||
|
@@ -995,7 +995,7 @@
|
||||||
|
trgp = tpdata->bits + tele_bm.h * tele_bm.w;
|
||||||
|
LG_memcpy(dstp, srcp, trgp - srcp);
|
||||||
|
|
||||||
|
- _fr_draw_bitmap(&tele_bm, _fdt_dist, FALSE, anch.ul.x, anch.ul.y);
|
||||||
|
+ _fr_draw_bitmap(&tele_bm, _fdt_dist, FALSE, anch.ul.x, anch.ul.y, 0, 0);
|
||||||
|
release_critter_bitmap_fast(ref);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
@@ -1016,11 +1016,15 @@
|
||||||
|
#endif
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
- _fr_draw_bitmap(tpdata, _fdt_dist, 0, anch.ul.x, anch.ul.y);
|
||||||
|
+ {
|
||||||
|
+ short native_w = 0, native_h = 0;
|
||||||
|
+ get_critter_native_size(REFID(ref), &native_w, &native_h);
|
||||||
|
+ _fr_draw_bitmap(tpdata, _fdt_dist, 0, anch.ul.x, anch.ul.y, native_w, native_h);
|
||||||
|
+ }
|
||||||
|
release_critter_bitmap_fast(ref);
|
||||||
|
} else {
|
||||||
|
tpdata = bitmaps_3d[o3drep + view];
|
||||||
|
- _fr_draw_bitmap(tpdata, _fdt_dist, 0, -1, -1);
|
||||||
|
+ _fr_draw_bitmap(tpdata, _fdt_dist, 0, -1, -1, 0, 0);
|
||||||
|
}
|
||||||
|
} break;
|
||||||
|
|
||||||
|
--- a/src/Libraries/3D/Source/Bitmap.c 2026-07-19 13:11:51.000000000 +0200
|
||||||
|
+++ b/src/Libraries/3D/Source/Bitmap.c 2026-09-27 20:44:47.378954871 +0200
|
||||||
|
@@ -88,6 +88,14 @@
|
||||||
|
// fix _g3d_bitmap_y_iscale = 0x010000;
|
||||||
|
long _g3d_bitmap_u_anchor = 0;
|
||||||
|
long _g3d_bitmap_v_anchor = 0;
|
||||||
|
+// Non-zero overrides the bitmap's own w/h in g3_bitmap_common()'s world-space
|
||||||
|
+// size computation below, decoupling a bitmap's on-screen size from its
|
||||||
|
+// actual pixel dimensions - set by g3_light_anchor_bitmap(), consumed and
|
||||||
|
+// reset back to 0 by g3_bitmap_common() so it never leaks into an unrelated
|
||||||
|
+// bitmap drawn via a different entry point (g3_bitmap()/g3_light_bitmap()/
|
||||||
|
+// do_bitmap() never set these).
|
||||||
|
+short _g3d_bitmap_w_override = 0;
|
||||||
|
+short _g3d_bitmap_h_override = 0;
|
||||||
|
fix _g3d_roll_matrix[6];
|
||||||
|
uchar *_g3d_bitmap_clut;
|
||||||
|
int _g3d_light_flag;
|
||||||
|
@@ -132,10 +140,13 @@
|
||||||
|
return (g3_bitmap_common(bm, (g3s_phandle)p));
|
||||||
|
}
|
||||||
|
|
||||||
|
-grs_vertex **g3_light_anchor_bitmap(grs_bitmap *bm, g3s_phandle p, short u_anchor, short v_anchor) {
|
||||||
|
+grs_vertex **g3_light_anchor_bitmap(grs_bitmap *bm, g3s_phandle p, short u_anchor, short v_anchor, short w_override,
|
||||||
|
+ short h_override) {
|
||||||
|
_g3d_light_flag = 2;
|
||||||
|
_g3d_bitmap_u_anchor = u_anchor;
|
||||||
|
_g3d_bitmap_v_anchor = v_anchor;
|
||||||
|
+ _g3d_bitmap_w_override = w_override;
|
||||||
|
+ _g3d_bitmap_h_override = h_override;
|
||||||
|
_g3d_bitmap_clut = (p->i & 0x00ff00) + grd_screen->ltab;
|
||||||
|
_g3d_bitmap_poly = vpl;
|
||||||
|
return (g3_bitmap_common(bm, p));
|
||||||
|
@@ -185,6 +196,15 @@
|
||||||
|
long rm2;
|
||||||
|
long rm3;
|
||||||
|
|
||||||
|
+ // Read and reset right at entry, before any early return below - a
|
||||||
|
+ // pending override must never survive to affect a later, unrelated
|
||||||
|
+ // bitmap draw that doesn't set it itself (see _g3d_bitmap_w_override's
|
||||||
|
+ // own comment).
|
||||||
|
+ short w_override = _g3d_bitmap_w_override;
|
||||||
|
+ short h_override = _g3d_bitmap_h_override;
|
||||||
|
+ _g3d_bitmap_w_override = 0;
|
||||||
|
+ _g3d_bitmap_h_override = 0;
|
||||||
|
+
|
||||||
|
#ifdef stereo_on
|
||||||
|
if (_g3d_stereo & 1) {
|
||||||
|
|
||||||
|
@@ -209,9 +229,11 @@
|
||||||
|
if ((p->codes & CC_CLIP_OVERFLOW) != 0)
|
||||||
|
return (0L);
|
||||||
|
|
||||||
|
- // copy a few things into locals
|
||||||
|
- bm_w = bm->w;
|
||||||
|
- bm_h = bm->h;
|
||||||
|
+ // copy a few things into locals - w_override/h_override, if set (see
|
||||||
|
+ // _g3d_bitmap_w_override's own comment), take the place of the bitmap's
|
||||||
|
+ // own pixel dimensions here.
|
||||||
|
+ bm_w = w_override ? w_override : bm->w;
|
||||||
|
+ bm_h = h_override ? h_override : bm->h;
|
||||||
|
|
||||||
|
tempL = p->gZ; // mov eax,[edi].z
|
||||||
|
tempL <<= 8; // sal eax,8 ;should be
|
||||||
|
--- a/src/Libraries/3D/Source/3d.h 2026-07-19 13:11:51.000000000 +0200
|
||||||
|
+++ b/src/Libraries/3D/Source/3d.h 2026-09-27 19:42:26.528424908 +0200
|
||||||
|
@@ -720,7 +720,8 @@
|
||||||
|
grs_vertex **g3_light_bitmap(grs_bitmap *bm, g3s_phandle p);
|
||||||
|
// MLA #pragma aux g3_light_bitmap "*" parm [esi] [edi] modify [eax ebx ecx
|
||||||
|
// edx];
|
||||||
|
-grs_vertex **g3_light_anchor_bitmap(grs_bitmap *bm, g3s_phandle p, short u_anchor, short v_anchor);
|
||||||
|
+grs_vertex **g3_light_anchor_bitmap(grs_bitmap *bm, g3s_phandle p, short u_anchor, short v_anchor, short w_override,
|
||||||
|
+ short h_override);
|
||||||
|
// MLA #pragma aux g3_light_anchor_bitmap "*" parm [esi] [edi] [eax] [edx]
|
||||||
|
// modify [eax ebx ecx edx];
|
||||||
|
grs_vertex **g3_full_light_bitmap(grs_bitmap *bm, grs_vertex **p);
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Builds the modding subproject - see README.md.
|
||||||
|
#
|
||||||
|
# Targets:
|
||||||
|
# all (default) - `hacked` and `mods`.
|
||||||
|
# build-image - builds the hacked-build-image Docker image
|
||||||
|
# (./hacked-build-image.sh). Only needed once, or after
|
||||||
|
# hacked-build-image/ or hacked/go.mod|go.sum changes.
|
||||||
|
# hacked - compiles hacked/ for Linux and Windows via that image,
|
||||||
|
# offline. Output lands in dist/.
|
||||||
|
# mdlp-image - builds the mdlp-image Docker image (./mdlp-image.sh).
|
||||||
|
# Only needed once, or after mdlp-image/ or
|
||||||
|
# mdlp/go.mod|go.sum changes.
|
||||||
|
# mods - builds every mod under mods/ via that image, offline.
|
||||||
|
# Set MODS="name1 name2" to build only those. Output
|
||||||
|
# lands in dist/mods/.
|
||||||
|
# animgallery_base - browses res/assets/ss_ee/data alone: every animated
|
||||||
|
# resource as a looping GIF in build/gallery-base/.
|
||||||
|
# animgallery_mods - same, but with every built mod under dist/mods/
|
||||||
|
# layered on top (later wins), in build/gallery-mods/ -
|
||||||
|
# shows what a mod actually changes without launching
|
||||||
|
# the game. Run `make mods` first.
|
||||||
|
# animgallery_dist - every built mod under dist/mods/ layered on top of
|
||||||
|
# the base game, like animgallery_mods, but restricted
|
||||||
|
# to only the resources a mod actually overrides, in
|
||||||
|
# build/gallery-dist/ - a focused "what did my mods
|
||||||
|
# change" view, in the base game's own colors (unlike
|
||||||
|
# a mod's own output alone, which has no palette
|
||||||
|
# resource and would render in grayscale).
|
||||||
|
# Run `make mods` first.
|
||||||
|
# (animgallery_base/_mods/_dist all write into this
|
||||||
|
# subproject's own build/, not the repo root's.)
|
||||||
|
# clean - removes dist/.
|
||||||
|
.PHONY: all build-image hacked mdlp-image mods animgallery_base animgallery_mods animgallery_dist clean
|
||||||
|
|
||||||
|
export MODS
|
||||||
|
|
||||||
|
all: hacked mods
|
||||||
|
|
||||||
|
build-image:
|
||||||
|
./hacked-build-image.sh
|
||||||
|
|
||||||
|
# If we're already running inside the hacked-build-image
|
||||||
|
# (HACKED_BUILD_IMAGE, set by its Dockerfile), compile directly instead of
|
||||||
|
# trying to docker-run it again - e.g. a CI job already using this image as
|
||||||
|
# its container. Otherwise, run it via Docker as usual.
|
||||||
|
hacked:
|
||||||
|
@if [ -n "$$HACKED_BUILD_IMAGE" ]; then \
|
||||||
|
bash hacked-build-image/build-hacked.sh; \
|
||||||
|
else \
|
||||||
|
./hacked-build-run-image.sh; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
mdlp-image:
|
||||||
|
./mdlp-image.sh
|
||||||
|
|
||||||
|
# Same already-in-the-image shortcut as `hacked` above, for MDLP_IMAGE.
|
||||||
|
mods:
|
||||||
|
@if [ -n "$$MDLP_IMAGE" ]; then \
|
||||||
|
bash mdlp-image/process-mods.sh; \
|
||||||
|
else \
|
||||||
|
./mdlp-run-image.sh; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
animgallery_base:
|
||||||
|
./gen_animgallery.sh -data res/assets/ss_ee/data -out modding/build/gallery-base
|
||||||
|
|
||||||
|
# Layers every built mod's mod/ folder on top of the base assets, later
|
||||||
|
# wins - same discovery pattern as res/run-mods.sh.
|
||||||
|
animgallery_mods:
|
||||||
|
@ARGS="-data res/assets/ss_ee/data"; \
|
||||||
|
for d in dist/mods/*/mod; do \
|
||||||
|
[ -d "$$d" ] && ARGS="$$ARGS -data modding/$$d"; \
|
||||||
|
done; \
|
||||||
|
./gen_animgallery.sh $$ARGS -out modding/build/gallery-mods
|
||||||
|
|
||||||
|
# Same layering as animgallery_mods, but -only-changed restricts the output
|
||||||
|
# to resources a mod layer actually overrides.
|
||||||
|
animgallery_dist:
|
||||||
|
@ARGS="-data res/assets/ss_ee/data"; \
|
||||||
|
FOUND=""; \
|
||||||
|
for d in dist/mods/*/mod; do \
|
||||||
|
[ -d "$$d" ] && ARGS="$$ARGS -data modding/$$d" && FOUND=1; \
|
||||||
|
done; \
|
||||||
|
if [ -z "$$FOUND" ]; then \
|
||||||
|
echo "PREFLIGHT FAIL: no built mods found under dist/mods/ - run 'make mods' first" >&2; \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
./gen_animgallery.sh $$ARGS -only-changed -out modding/build/gallery-dist
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf dist
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
# Modding
|
||||||
|
|
||||||
|
Tools and reference material for modding System Shock (1994) itself - distinct
|
||||||
|
from the rest of this repository, which is the [QuestShock](../README.md) VR
|
||||||
|
port of the game engine. Everything modding-related lives under this folder.
|
||||||
|
|
||||||
|
`hacked` and `ss-specs` come from the [InkyBlackness](https://inkyblackness.github.io)
|
||||||
|
project; `sqweek-dialog` is a separate third-party dependency of `hacked`.
|
||||||
|
All three are vendored unmodified under `src/`, each under its own upstream
|
||||||
|
license (see below) - none is relicensed by this repository's own MIT
|
||||||
|
license. `mdlp` (also under `src/`) is first-party code written for this
|
||||||
|
repository.
|
||||||
|
|
||||||
|
## 1. Layout
|
||||||
|
|
||||||
|
- `src/` - every vendored and first-party source tree, kept apart from the
|
||||||
|
Docker/script scaffolding that builds them:
|
||||||
|
- `hacked/` - [InkyBlackness HackEd](https://github.com/inkyblackness/hacked),
|
||||||
|
the graphical mod editor. A vendored source snapshot (New BSD License),
|
||||||
|
taken from the `main` branch on 2026-09-26. To update it, replace this
|
||||||
|
directory with a fresh checkout and update that date.
|
||||||
|
- `hacked-patches/` - numbered `patch -p1` files applied to a scratch copy
|
||||||
|
of `hacked/` at build time (same pattern as the root project's
|
||||||
|
`android/engine-patches/`, see the repository root's
|
||||||
|
[AGENTS.md](../AGENTS.md) section 6) - `hacked/` itself is never edited
|
||||||
|
in place. See section 1.1 for what each patch does.
|
||||||
|
- `sqweek-dialog/` - `github.com/sqweek/dialog`, `hacked`'s native
|
||||||
|
file-dialog dependency. Vendored at the exact version `hacked/go.mod`
|
||||||
|
would otherwise pull from the module proxy (ISC License), so it can be
|
||||||
|
patched the same way as `hacked/` itself (see section 1.1).
|
||||||
|
- `sqweek-dialog-patches/` - patches for `sqweek-dialog/`, same pattern as
|
||||||
|
`hacked-patches/` (section 1.1).
|
||||||
|
- `mdlp/` - the **Mod Definition Language Processor** (section 4). First-party
|
||||||
|
Go source, not vendored - edit it directly, no patch system needed.
|
||||||
|
- `ss-specs/` - [InkyBlackness ss-specs](https://github.com/inkyblackness/ss-specs),
|
||||||
|
the unofficial file-format specifications the editor (and any other
|
||||||
|
modding tool) is written against. Reference documentation only, not
|
||||||
|
built. A vendored snapshot (CC BY-NC-SA 4.0 - **non-commercial**, unlike
|
||||||
|
the rest of this repository), taken from the `main` branch on
|
||||||
|
2026-09-26.
|
||||||
|
- `hacked-build-image/` - the Dockerfile (and supporting scripts) for the
|
||||||
|
`hacked` build environment: the Go toolchain plus every dependency
|
||||||
|
`src/hacked/go.mod` pins, fetched once at image-build time so the actual
|
||||||
|
build needs no network access. Mirrors the main project's own
|
||||||
|
`build-image/` (see the repository root's [README.md](../README.md)
|
||||||
|
section 4) one level down, for this subproject.
|
||||||
|
- `hacked-build-image.sh` / `hacked-build-run-image.sh` /
|
||||||
|
`hacked-build-upload-image.sh` - build the image, run it to compile
|
||||||
|
`hacked/`, and push it to a registry, respectively. Same pattern as the
|
||||||
|
root project's own `build-image.sh`/`run-image.sh`/`upload-image.sh`.
|
||||||
|
- `mdlp-image/`, `mdlp-image.sh` / `mdlp-run-image.sh` / `mdlp-upload-image.sh` -
|
||||||
|
the same pattern again, for `mdlp` (section 4.2).
|
||||||
|
- `mods/` - mod definitions in the Mod Definition Language (section 4).
|
||||||
|
- `Makefile` - `hacked` (default) builds `hacked/` via the image;
|
||||||
|
`build-image` (re)builds that image; `mdlp-image` builds the `mdlp` image;
|
||||||
|
`mods` processes every mod under `mods/` via that image; `clean` removes
|
||||||
|
`dist/`.
|
||||||
|
- `dist/` - build output (git-ignored): `make hacked` under `linux/`/`win/`,
|
||||||
|
`make mods` under `mods/`.
|
||||||
|
|
||||||
|
Shockolate (the engine `../engine/` vendors) already has its own modding
|
||||||
|
support (level/resource files, `res/data/`); this folder is about the
|
||||||
|
external tools that create and inspect that content, not about changing the
|
||||||
|
engine itself.
|
||||||
|
|
||||||
|
### 1.1. Patch reference
|
||||||
|
|
||||||
|
`src/hacked-patches/` and `src/sqweek-dialog-patches/` (applied to scratch
|
||||||
|
copies of `src/hacked/` and `src/sqweek-dialog/` respectively) are both
|
||||||
|
plain, numbered patch files applied in order at build time - neither
|
||||||
|
vendored source is ever modified in place. What each one does:
|
||||||
|
|
||||||
|
`src/hacked-patches/`:
|
||||||
|
|
||||||
|
- **`01-use-patched-dialog.patch`** - adds a `go.mod` `replace` directive
|
||||||
|
pointing `github.com/sqweek/dialog` at the local, patched
|
||||||
|
`../sqweek-dialog` instead of the upstream module, so `hacked` (and
|
||||||
|
anything else building against this source, such as `mdlp`/`animgallery`)
|
||||||
|
picks up the crash fix below.
|
||||||
|
- **`02-fix-hashall-race.patch`** - fixes a real data race in
|
||||||
|
`ss1/resource/ChangeNotifier.go`'s `hashAll()`: it hashes many resources
|
||||||
|
concurrently (one goroutine per ID) to detect what changed after a
|
||||||
|
modification, but only guarded the resource *lookup* with a mutex, not
|
||||||
|
the actual block-reading inside `hashResource()` - resources from the
|
||||||
|
same underlying file share a stateful reader, so concurrent unguarded
|
||||||
|
reads corrupted each other, non-deterministically. Found via `go build
|
||||||
|
-race` after `animgallery` produced a different resource count on every
|
||||||
|
run against the exact same input. The mutex now guards the whole hash
|
||||||
|
computation per resource, not just the lookup.
|
||||||
|
|
||||||
|
`src/sqweek-dialog-patches/`:
|
||||||
|
|
||||||
|
- **`01-xinitthreads.patch`** - adds `XInitThreads()` before `gtk_init()`
|
||||||
|
in the Linux backend. Without it, `hacked` segfaults the moment a native
|
||||||
|
GTK3 dialog (file/folder browse) opens while a GLFW/OpenGL context is
|
||||||
|
live: GLFW's own X11 backend installs a process-wide Xlib error handler
|
||||||
|
that conflicts with GTK's unless Xlib has been told up front that it
|
||||||
|
will be used from multiple threads.
|
||||||
|
|
||||||
|
## 2. Building `hacked`
|
||||||
|
|
||||||
|
Packages `dist/<version>.linux64.tgz` and `dist/<version>.win64.zip` (native
|
||||||
|
Linux build, Windows amd64 cross-compiled via mingw-w64 - both from the same
|
||||||
|
source, in one run).
|
||||||
|
|
||||||
|
**Prerequisites:** Docker - same as the root project, nothing modding-specific
|
||||||
|
to install locally.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd modding
|
||||||
|
|
||||||
|
# 1. Build the hacked-build-image (once, or after hacked-build-image/ or
|
||||||
|
# src/hacked/go.mod|go.sum changes)
|
||||||
|
./hacked-build-image.sh
|
||||||
|
|
||||||
|
# 2. Compile hacked/ for Linux and Windows
|
||||||
|
make hacked
|
||||||
|
```
|
||||||
|
|
||||||
|
`<version>` is `hacked-<date>`, not an InkyBlackness release version:
|
||||||
|
`hacked/` is a vendored snapshot here, not its own git checkout, so there is
|
||||||
|
no tag to derive a real version from (see
|
||||||
|
`hacked-build-image/build-hacked.sh`'s own comment).
|
||||||
|
|
||||||
|
Run the result: `dist/linux/hacked/hacked` on Linux,
|
||||||
|
`dist\win\hacked\hacked.exe` on Windows. Needs OpenGL 3.2; usage is documented
|
||||||
|
on [hacked's own wiki](https://github.com/inkyblackness/hacked/wiki).
|
||||||
|
|
||||||
|
### 2.1. Publishing the build-image
|
||||||
|
|
||||||
|
Same registry as the root project's own build-image (see the repository
|
||||||
|
root's `registry.env.example`), under a separate image name so the two don't
|
||||||
|
collide:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./hacked-build-upload-image.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Reads `REGISTRY`/`REGISTRY_USER`/`REGISTRY_PASSWORD` and (optionally)
|
||||||
|
`HACKED_REGISTRY_IMAGE` from `../registry.env`, same as the root scripts.
|
||||||
|
Don't bump `hacked-build-image/VERSION` just because the Dockerfile changed -
|
||||||
|
only once a build with the current version has actually succeeded, same
|
||||||
|
policy as the root project's own `build-image/VERSION`.
|
||||||
|
|
||||||
|
## 3. Example: Editing a message
|
||||||
|
|
||||||
|
`hacked` has no inline text editor - every text field is edited by right-click
|
||||||
|
-> **Copy to Clipboard**, editing the text in an external editor, then
|
||||||
|
right-click -> **Copy from Clipboard** back into the field. Example: changing
|
||||||
|
Rebecca Lansing's first email.
|
||||||
|
|
||||||
|
1. Run `dist/linux/hacked/hacked` (or the Windows build).
|
||||||
|
2. **Window -> Project.** Under **Static World Data**, **Add...** and browse to
|
||||||
|
the game's whole `res/assets/ss_ee/data/` directory (see the root
|
||||||
|
[README.md](../README.md) section 3 for where that comes from), so every
|
||||||
|
`.res`/`.dat` file is available.
|
||||||
|
3. **Window -> Messages** (F5). **Message Type**: `Mails`. **Index**: `26`
|
||||||
|
(Rebecca's first mail). Edit **Sender**/**Subject**/**Title**/the message
|
||||||
|
text via the clipboard round-trip above.
|
||||||
|
4. Back in the **Project** window, under **Mod Location**, click **Save**
|
||||||
|
(first time: **Browse...** to an empty folder, for example
|
||||||
|
`./modding/mymod`).
|
||||||
|
5. Test it from the root project's desktop build:
|
||||||
|
```sh
|
||||||
|
./dist/run.sh "$(pwd)/modding/mymod"
|
||||||
|
```
|
||||||
|
The mod-folder argument **must be an absolute path**: `run.sh` `cd`s into
|
||||||
|
`dist/` before launching the engine, so a relative path resolves against
|
||||||
|
the wrong directory and the mod silently fails to load (the engine's
|
||||||
|
`ProcessModDirectory()` reports nothing on a bad path). When the path is
|
||||||
|
right, the terminal shows `ProcessModDirectory ...` / `Found resource
|
||||||
|
file: ...` / `Loading mod file ...` for confirmation.
|
||||||
|
|
||||||
|
## 4. Mod Definition Language (mods/)
|
||||||
|
|
||||||
|
MDL is a small, declarative JSON format for describing a mod's content -
|
||||||
|
one file per piece of content (a message override, an upscaled sprite, and
|
||||||
|
so on), naming the game resource it touches and the values to set, rather
|
||||||
|
than the resource's actual binary data. `mdlp` (the **Mod Definition
|
||||||
|
Language Processor**, `mdlp/`) reads these files and produces the same kind
|
||||||
|
of mod folder `hacked` itself would save, reproducibly and offline. Since a
|
||||||
|
plain-text description is all that's checked into git, mods stay reviewable
|
||||||
|
and reproducible from the game's own data alone (see section 4.3) - unlike
|
||||||
|
the workflow in section 3, whose result only ever lives in whatever you
|
||||||
|
clicked through in `hacked`.
|
||||||
|
|
||||||
|
### 4.1. Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
mods/
|
||||||
|
<mod-name>/
|
||||||
|
messages/
|
||||||
|
<label>.json # one message's overrides
|
||||||
|
<label>.txt # optional: a text field's body, referenced by ...File
|
||||||
|
```
|
||||||
|
|
||||||
|
Each `messages/*.json` overrides one message (email, log, or fragment) by
|
||||||
|
type and index. Only fields present in the file are changed - everything
|
||||||
|
else is read from the game's own data (Static World Data), the same
|
||||||
|
inheritance the Messages window in `hacked` shows via its "(read-only)"
|
||||||
|
label:
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `type` | `Mails`, `Logs`, or `Fragments` |
|
||||||
|
| `index` | The message's index within that type |
|
||||||
|
| `language` | `Default`, `French`, or `German` - omit for `Default` |
|
||||||
|
| `title`, `sender`, `subject` | Plain strings |
|
||||||
|
| `verboseText` / `verboseTextFile` | The long body, inline or from a sibling file (avoids escaping newlines/quotes in JSON) |
|
||||||
|
| `terseText` / `terseTextFile` | The short body, inline or from a sibling file |
|
||||||
|
| `nextMessage`, `isInterrupt`, `colorIndex`, `leftDisplay`, `rightDisplay` | See `ElectronicMessage` in `src/hacked/ss1/content/text/ElectronicMessage.go` |
|
||||||
|
|
||||||
|
`mods/mymod/` is a worked example matching section 3's Rebecca's-mail edit,
|
||||||
|
expressed as MDL instead of a `hacked` session.
|
||||||
|
|
||||||
|
### 4.2. `mdlp`
|
||||||
|
|
||||||
|
`src/mdlp/` is first-party Go source (not vendored - edit it directly, no
|
||||||
|
patch system needed). It reuses `hacked`'s own resource read/write code
|
||||||
|
(`ss1/edit`, `ss1/world`, `ss1/content/text`) through a `go.mod` `replace`
|
||||||
|
pointing at the sibling `../hacked` (both under `src/`), instead of
|
||||||
|
reimplementing System Shock's file formats. It needs no GUI, GLFW, or GTK -
|
||||||
|
only the packages that model resource data.
|
||||||
|
|
||||||
|
`mdlp-image/` is its own Docker build/runtime environment, separate from
|
||||||
|
`hacked-build-image/`: mod processing is expected to grow further external
|
||||||
|
tool dependencies (image/audio conversion, etc.) of its own over time,
|
||||||
|
independent of `hacked`'s GTK/GLFW/mingw toolchain.
|
||||||
|
|
||||||
|
### 4.3. Building mods
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd modding
|
||||||
|
|
||||||
|
# 1. Build the mdlp-image (once, or after mdlp-image/ or
|
||||||
|
# src/mdlp/go.mod|go.sum changes)
|
||||||
|
./mdlp-image.sh
|
||||||
|
|
||||||
|
# 2. Process every mod under mods/
|
||||||
|
make mods
|
||||||
|
|
||||||
|
# ...or only some of them:
|
||||||
|
MODS="mymod" make mods
|
||||||
|
```
|
||||||
|
|
||||||
|
Needs the game's own data directory at `../res/assets/ss_ee/data` (see the
|
||||||
|
root [README.md](../README.md) section 3) as read-only reference data, so
|
||||||
|
overrides that touch only some fields of a message can inherit the rest.
|
||||||
|
|
||||||
|
Output lands in `dist/mods/<mod-name>/mod/` (the same shape `hacked`'s own
|
||||||
|
Project window Save produces) for each mod, plus one combined
|
||||||
|
`dist/questshock-mods.zip` packaging all of them together. Nothing but
|
||||||
|
`mods/`'s own JSON/text files needs to be checked into git - the game's own
|
||||||
|
data (already required locally, already git-ignored) plus those files is
|
||||||
|
always enough to reproduce this output from scratch.
|
||||||
|
|
||||||
|
Test every built mod at once with `run-mods.sh` (Linux) / `run-mods.bat`
|
||||||
|
(Windows), copied into the root project's own `dist/`/`dist-win/` by
|
||||||
|
`make dist` (root [README.md](../README.md) section 4) alongside the plain
|
||||||
|
`run.sh`/`run.bat`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
../dist/run-mods.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
It scans `dist/mods/*/mod` and passes each one it finds to `run.sh` as a
|
||||||
|
separate, absolute argument - the engine's `ProcessModDirectory()` only scans
|
||||||
|
one directory level deep (see `res/run-mods.sh`'s own comment), so it can't
|
||||||
|
simply be pointed at `dist/mods/` itself. To test a single mod instead, use
|
||||||
|
`run.sh` directly the same way as section 3's step 5:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
../dist/run.sh "$(pwd)/dist/mods/mymod/mod"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4. Publishing the mdlp-image
|
||||||
|
|
||||||
|
Same as `hacked-build-image/` (section 2.1): `./mdlp-upload-image.sh` reads
|
||||||
|
`REGISTRY`/`REGISTRY_USER`/`REGISTRY_PASSWORD` and (optionally)
|
||||||
|
`MDLP_REGISTRY_IMAGE` from `../registry.env`. Don't bump
|
||||||
|
`mdlp-image/VERSION` just because the Dockerfile changed - only once a build
|
||||||
|
with the current version has actually succeeded, same policy as the root
|
||||||
|
project's own `build-image/VERSION`.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Builds and runs modding/src/animgallery/ (see README.md section 4) inside
|
||||||
|
# the mdlp-image's Go toolchain - animgallery has no cgo dependencies of its
|
||||||
|
# own, so it reuses that image rather than needing one of its own.
|
||||||
|
#
|
||||||
|
# Every argument is forwarded verbatim to animgallery itself. -data is
|
||||||
|
# repeatable and layers directories in the order given, later wins - the
|
||||||
|
# whole repository is mounted at /workspace here, so paths are relative to
|
||||||
|
# the repository root, for example:
|
||||||
|
#
|
||||||
|
# ./gen_animgallery.sh -data res/assets/ss_ee/data -out modding/build/gallery
|
||||||
|
# ./gen_animgallery.sh -data res/assets/ss_ee/data \
|
||||||
|
# -data modding/dist/mods/mymod/mod -out modding/build/gallery-mymod
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||||
|
MODDING_ROOT="$(pwd)"
|
||||||
|
REPO_ROOT="$(cd .. && pwd)"
|
||||||
|
|
||||||
|
fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
command -v docker >/dev/null 2>&1 \
|
||||||
|
|| fail "docker not found in PATH"
|
||||||
|
|
||||||
|
IMAGE="${MDLP_REGISTRY_IMAGE:-questshock/mdlp}"
|
||||||
|
if [ -f "$MODDING_ROOT/../registry.env" ]; then
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "$MODDING_ROOT/../registry.env"
|
||||||
|
IMAGE="${MDLP_REGISTRY_IMAGE:-$IMAGE}"
|
||||||
|
fi
|
||||||
|
IMAGE_TAG="$(<"$MODDING_ROOT/mdlp-image/VERSION")"
|
||||||
|
[ -n "$IMAGE_TAG" ] || fail "mdlp-image/VERSION is empty"
|
||||||
|
IMAGE="$IMAGE:$IMAGE_TAG"
|
||||||
|
|
||||||
|
docker image inspect "$IMAGE" >/dev/null 2>&1 \
|
||||||
|
|| fail "$IMAGE not found locally - run ./mdlp-image.sh first"
|
||||||
|
|
||||||
|
TTY_FLAGS="-i"
|
||||||
|
[ -t 1 ] && TTY_FLAGS="-it"
|
||||||
|
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
docker run --rm $TTY_FLAGS \
|
||||||
|
-v "$REPO_ROOT:/workspace" \
|
||||||
|
-w /workspace \
|
||||||
|
-e HOST_UID="$(id -u)" \
|
||||||
|
-e HOST_GID="$(id -g)" \
|
||||||
|
"$IMAGE" \
|
||||||
|
bash -c '
|
||||||
|
set -euo pipefail
|
||||||
|
# animgallery, like mdlp, builds against ../hacked via its own
|
||||||
|
# go.mod replace - stage a patched copy (see src/hacked-patches/)
|
||||||
|
# rather than building against the unpatched vendored source.
|
||||||
|
SCRATCH_DIR="modding/build/animgallery-src"
|
||||||
|
rm -rf "$SCRATCH_DIR"
|
||||||
|
mkdir -p "$SCRATCH_DIR"
|
||||||
|
bash modding/mdlp-image/stage-hacked.sh modding "$SCRATCH_DIR/hacked"
|
||||||
|
cp -a modding/src/animgallery "$SCRATCH_DIR/animgallery"
|
||||||
|
(cd "$SCRATCH_DIR/animgallery" && go build -o /tmp/animgallery .)
|
||||||
|
exec /tmp/animgallery "$@"
|
||||||
|
' _ "$@"
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Builds the hacked build-image (hacked-build-image/Dockerfile) locally, tagged with
|
||||||
|
# hacked-build-image/VERSION and :latest. Does not push - test it with
|
||||||
|
# ./hacked-build-run-image.sh first, then publish with
|
||||||
|
# ./hacked-build-upload-image.sh.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||||
|
ROOT="$(pwd)"
|
||||||
|
|
||||||
|
fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
command -v docker >/dev/null 2>&1 \
|
||||||
|
|| fail "docker not found in PATH"
|
||||||
|
|
||||||
|
IMAGE="${HACKED_REGISTRY_IMAGE:-questshock/hacked-builder}"
|
||||||
|
if [ -f "$ROOT/../registry.env" ]; then
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "$ROOT/../registry.env"
|
||||||
|
IMAGE="${HACKED_REGISTRY_IMAGE:-$IMAGE}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
VERSION="$(<"$ROOT/hacked-build-image/VERSION")"
|
||||||
|
[ -n "$VERSION" ] || fail "hacked-build-image/VERSION is empty"
|
||||||
|
|
||||||
|
echo "== Building $IMAGE:$VERSION =="
|
||||||
|
docker build \
|
||||||
|
-f "$ROOT/hacked-build-image/Dockerfile" \
|
||||||
|
-t "$IMAGE:$VERSION" \
|
||||||
|
-t "$IMAGE:latest" \
|
||||||
|
"$ROOT"
|
||||||
|
|
||||||
|
echo "== Done =="
|
||||||
|
echo "Image: $IMAGE:$VERSION (and :latest)"
|
||||||
|
echo "Test it with ./hacked-build-run-image.sh, then publish with ./hacked-build-upload-image.sh"
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Build environment for modding/src/hacked/ (vendored from
|
||||||
|
# https://github.com/inkyblackness/hacked, see ../README.md). Fetches the Go
|
||||||
|
# toolchain and this module's dependencies once, here, at image-build time, so
|
||||||
|
# ../hacked-build-run-image.sh can compile fully offline afterward. Rebuild
|
||||||
|
# with ../hacked-build-image.sh whenever a version below changes, or whenever
|
||||||
|
# ../src/hacked/go.mod or go.sum changes (its dependencies are baked into the
|
||||||
|
# image's module cache).
|
||||||
|
FROM ubuntu:22.04
|
||||||
|
|
||||||
|
# go.mod's own "go" directive is a minimum toolchain version, not a version to
|
||||||
|
# fetch - pin a stable release that already satisfies it so `go build` never
|
||||||
|
# needs to fetch a newer toolchain over the network at build time.
|
||||||
|
ARG GO_VERSION=1.27.1
|
||||||
|
|
||||||
|
# build-essential/pkg-config/curl/ca-certificates/git/gosu: base toolchain.
|
||||||
|
# mingw-w64: the Windows cross-build. libgl1-mesa-dev/xorg-dev: go-gl/glfw's
|
||||||
|
# cgo bindings (Linux build only - the Windows cross-build talks to the Win32
|
||||||
|
# API directly, via mingw-w64's own headers). libgtk-3-dev:
|
||||||
|
# github.com/sqweek/dialog's Linux backend uses GTK3 via cgo (`pkg-config
|
||||||
|
# gtk+-3.0`) for native file/message dialogs. zip: packages the Windows build
|
||||||
|
# (see build-hacked.sh) - tar, for the Linux .tgz, is already part of the
|
||||||
|
# base image.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||||
|
build-essential pkg-config curl ca-certificates git gosu \
|
||||||
|
mingw-w64 \
|
||||||
|
libgl1-mesa-dev xorg-dev \
|
||||||
|
libgtk-3-dev \
|
||||||
|
zip \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN curl -sSLO "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" \
|
||||||
|
&& tar -C /usr/local -xzf "go${GO_VERSION}.linux-amd64.tar.gz" \
|
||||||
|
&& rm "go${GO_VERSION}.linux-amd64.tar.gz"
|
||||||
|
ENV PATH=/usr/local/go/bin:${PATH}
|
||||||
|
ENV GOTOOLCHAIN=local
|
||||||
|
ENV GOPATH=/opt/go
|
||||||
|
|
||||||
|
# Populates /opt/go/pkg/mod with every dependency hacked/go.mod pins, so the
|
||||||
|
# container never needs network access to build it. Only go.mod/go.sum are
|
||||||
|
# copied in for this - not the rest of the source - so editing hacked's own
|
||||||
|
# .go files doesn't invalidate this layer's cache.
|
||||||
|
COPY src/hacked/go.mod src/hacked/go.sum /tmp/hacked-deps/
|
||||||
|
RUN cd /tmp/hacked-deps && go mod download && rm -rf /tmp/hacked-deps
|
||||||
|
|
||||||
|
COPY hacked-build-image/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
|
COPY hacked-build-image/build-hacked.sh /usr/local/bin/build-hacked.sh
|
||||||
|
# 755, not +x: the entrypoint drops to a `builder` user matching the *host's*
|
||||||
|
# UID/GID (see docker-entrypoint.sh), which is never in these root-owned
|
||||||
|
# files' group, so it needs the image's own explicit world-read+execute here -
|
||||||
|
# `chmod +x` alone only adds execute bits on top of whatever "other"
|
||||||
|
# permissions the source file happened to have (host umask-dependent).
|
||||||
|
RUN chmod 755 /usr/local/bin/docker-entrypoint.sh /usr/local/bin/build-hacked.sh
|
||||||
|
|
||||||
|
WORKDIR /workspace
|
||||||
|
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||||
|
CMD ["/usr/local/bin/build-hacked.sh"]
|
||||||
|
|
||||||
|
# Marks a shell as already running inside this image, same convention as the
|
||||||
|
# main project's own build-image (see ../../build-image/Dockerfile) - lets
|
||||||
|
# ../Makefile compile directly instead of trying to docker-run this image
|
||||||
|
# again from inside itself (e.g. a CI job already using this image as its
|
||||||
|
# container).
|
||||||
|
ENV HACKED_BUILD_IMAGE=1
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
1
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Compiles modding/src/hacked/ for Linux and Windows (amd64), entirely
|
||||||
|
# offline - every Go module dependency was already fetched into the
|
||||||
|
# build-image at image-build time (see Dockerfile). Baked into the image at
|
||||||
|
# /usr/local/bin/build-hacked.sh (see Dockerfile's CMD) - run via
|
||||||
|
# ../hacked-build-run-image.sh, which bind-mounts modding/ at /workspace, not
|
||||||
|
# directly.
|
||||||
|
#
|
||||||
|
# Does not follow hacked/build.sh (its own upstream build script): that script
|
||||||
|
# assumes hacked/ is its own git checkout (it derives its version from `git
|
||||||
|
# describe --tags`), which isn't true here - hacked/ is a vendored snapshot,
|
||||||
|
# not a git submodule, so `git describe` there would silently pick up
|
||||||
|
# *questshock's* tags instead. Version is a plain build-date string instead.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
MODDING_ROOT="$(pwd)"
|
||||||
|
OUT_DIR="$MODDING_ROOT/dist"
|
||||||
|
SCRATCH_DIR="$MODDING_ROOT/build"
|
||||||
|
HACKED_SRC="$SCRATCH_DIR/hacked"
|
||||||
|
DIALOG_SRC="$SCRATCH_DIR/sqweek-dialog"
|
||||||
|
|
||||||
|
VERSION="hacked-$(date -u +%Y%m%d)"
|
||||||
|
echo "Version: $VERSION"
|
||||||
|
|
||||||
|
echo "== Preparing scratch, patched copies of hacked/ and sqweek-dialog/ =="
|
||||||
|
# hacked/ and sqweek-dialog/ are vendored unmodified (see README.md) - never
|
||||||
|
# built or patched in place. hacked-patches/'s go.mod edit points
|
||||||
|
# sqweek/dialog at ../sqweek-dialog (a sibling directory here), so both
|
||||||
|
# scratch copies must keep that same relative layout.
|
||||||
|
rm -rf "$SCRATCH_DIR"
|
||||||
|
mkdir -p "$SCRATCH_DIR"
|
||||||
|
cp -a "$MODDING_ROOT/src/hacked" "$HACKED_SRC"
|
||||||
|
cp -a "$MODDING_ROOT/src/sqweek-dialog" "$DIALOG_SRC"
|
||||||
|
for p in "$MODDING_ROOT"/src/hacked-patches/*.patch; do
|
||||||
|
patch -p1 -d "$HACKED_SRC" < "$p"
|
||||||
|
done
|
||||||
|
for p in "$MODDING_ROOT"/src/sqweek-dialog-patches/*.patch; do
|
||||||
|
patch -p1 -d "$DIALOG_SRC" < "$p"
|
||||||
|
done
|
||||||
|
|
||||||
|
rm -rf "$OUT_DIR"
|
||||||
|
mkdir -p "$OUT_DIR/linux/hacked" "$OUT_DIR/win/hacked"
|
||||||
|
|
||||||
|
cd "$HACKED_SRC"
|
||||||
|
|
||||||
|
echo "== Building Linux amd64 =="
|
||||||
|
go build -gcflags "-d=checkptr=0" -ldflags "-X main.version=$VERSION" \
|
||||||
|
-trimpath -o "$OUT_DIR/linux/hacked/hacked" .
|
||||||
|
|
||||||
|
echo "== Preparing the Windows icon/version-info resource =="
|
||||||
|
# cmd/link automatically links in a *.syso file sitting in the main package
|
||||||
|
# directory during a Windows build - no explicit flag needed, but it does mean
|
||||||
|
# generating it (via windres, from hacked.rc/.ico/.exe.manifest) has to happen
|
||||||
|
# right here, before go build runs, and be cleaned up after regardless of
|
||||||
|
# outcome so it never leaks into a Linux build run from the same checkout.
|
||||||
|
WIN_RES_DIR="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$WIN_RES_DIR"; rm -f "$HACKED_SRC/main-res.syso"' EXIT
|
||||||
|
cp "$HACKED_SRC"/_resources/build/win/* "$WIN_RES_DIR/"
|
||||||
|
# No git tag to derive a real version from (hacked/ is a vendored snapshot,
|
||||||
|
# not its own checkout) - same date-based fallback hacked/build.sh itself
|
||||||
|
# uses when `git describe` fails.
|
||||||
|
sed -i "s/§MAJOR/$(date -u +%Y)/;s/§MINOR/$(date -u +%m)/;s/§PATCH/$(date -u +%d)/" \
|
||||||
|
"$WIN_RES_DIR/hacked.exe.manifest"
|
||||||
|
x86_64-w64-mingw32-windres -o "$HACKED_SRC/main-res.syso" "$WIN_RES_DIR/hacked.rc"
|
||||||
|
|
||||||
|
echo "== Building Windows amd64 (cross-compiled via mingw-w64) =="
|
||||||
|
# -extldflags -static-libgcc/-static-libstdc++: statically links the mingw
|
||||||
|
# C/C++ runtime into hacked.exe, the same technique the main project's own
|
||||||
|
# Windows cross-build uses (see ../../build-image/Dockerfile) - avoids having
|
||||||
|
# to locate and ship libgcc_s_seh-1.dll/libstdc++-6.dll (their exact path
|
||||||
|
# depends on the mingw-w64 package's gcc version). libwinpthread-1.dll still
|
||||||
|
# needs shipping alongside the .exe; it isn't part of that static link.
|
||||||
|
GOOS=windows GOARCH=amd64 CGO_ENABLED=1 \
|
||||||
|
CC=x86_64-w64-mingw32-gcc CXX=x86_64-w64-mingw32-g++ \
|
||||||
|
go build -gcflags "-d=checkptr=0" \
|
||||||
|
-ldflags "-X main.version=$VERSION -H=windowsgui -extldflags '-static-libgcc -static-libstdc++'" \
|
||||||
|
-trimpath -o "$OUT_DIR/win/hacked/hacked.exe" .
|
||||||
|
cp /usr/x86_64-w64-mingw32/lib/libwinpthread-1.dll "$OUT_DIR/win/hacked/"
|
||||||
|
|
||||||
|
echo "== Copying distribution resources =="
|
||||||
|
for os in linux win; do
|
||||||
|
packageDir="$OUT_DIR/$os/hacked"
|
||||||
|
cp "$HACKED_SRC/LICENSE" "$packageDir/"
|
||||||
|
cp -R "$HACKED_SRC/_resources/dist/." "$packageDir/"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "== Packaging =="
|
||||||
|
# $VERSION (hacked-<date>) is already a complete, self-describing name - no
|
||||||
|
# separate package-name prefix needed on top of it.
|
||||||
|
(cd "$OUT_DIR/linux" && tar -czf "$OUT_DIR/$VERSION.linux64.tgz" hacked)
|
||||||
|
(cd "$OUT_DIR/win" && zip -rq "$OUT_DIR/$VERSION.win64.zip" hacked)
|
||||||
|
|
||||||
|
echo "== Done =="
|
||||||
|
echo "Linux: $OUT_DIR/$VERSION.linux64.tgz"
|
||||||
|
echo "Windows: $OUT_DIR/$VERSION.win64.zip"
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Drops from root (needed to read the Go toolchain and module cache under
|
||||||
|
# /usr/local/go and /opt/go) to a user matching the host's UID/GID, so
|
||||||
|
# anything written into the bind-mounted repo (hacked/_build/, ...) is owned
|
||||||
|
# by the host user instead of root.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
USER_UID="${HOST_UID:-1000}"
|
||||||
|
USER_GID="${HOST_GID:-1000}"
|
||||||
|
|
||||||
|
groupadd -g "$USER_GID" builder 2>/dev/null || true
|
||||||
|
useradd -u "$USER_UID" -g "$USER_GID" -m -s /bin/bash builder 2>/dev/null || true
|
||||||
|
|
||||||
|
# /workspace (the bind-mounted repo) may actually be owned by a group the
|
||||||
|
# host user only has via *supplementary* membership rather than their
|
||||||
|
# primary GID above - e.g. a VirtualBox vboxsf shared folder, which shows
|
||||||
|
# up as root:vboxsf on the host and would otherwise be inaccessible to
|
||||||
|
# `builder` here (falls through to "other", which vboxsf's default mode
|
||||||
|
# leaves with no permissions at all). Join whatever group actually owns
|
||||||
|
# /workspace too, if it differs.
|
||||||
|
WORKSPACE_GID="$(stat -c %g /workspace 2>/dev/null || true)"
|
||||||
|
if [ -n "$WORKSPACE_GID" ] && [ "$WORKSPACE_GID" != "$USER_GID" ]; then
|
||||||
|
groupadd -g "$WORKSPACE_GID" workspace 2>/dev/null || true
|
||||||
|
usermod -aG "$WORKSPACE_GID" builder 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The Go module cache under /opt/go was populated as root at image-build
|
||||||
|
# time (see Dockerfile) - readable by anyone, but go build also wants to
|
||||||
|
# write GOCACHE (build cache) under there by default. Point it at a
|
||||||
|
# per-run tmpfs-friendly location inside the builder's own home instead of
|
||||||
|
# fighting permissions on the image's baked-in GOPATH.
|
||||||
|
export GOCACHE=/home/builder/.cache/go-build
|
||||||
|
export GOMODCACHE=/opt/go/pkg/mod
|
||||||
|
|
||||||
|
exec gosu builder "$@"
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Runs the hacked build-image against this folder to compile hacked/ (the
|
||||||
|
# vendored InkyBlackness HackEd snapshot, see README.md) for Linux and
|
||||||
|
# Windows, entirely offline - every Go module dependency it needs was already
|
||||||
|
# fetched into the image by ./hacked-build-image.sh.
|
||||||
|
#
|
||||||
|
# Output lands in dist/ (both platforms, packaged as .tgz/.zip).
|
||||||
|
#
|
||||||
|
# Usage: ./hacked-build-run-image.sh [command...]
|
||||||
|
# With no arguments, runs the image's baked-in build-hacked.sh (its default
|
||||||
|
# CMD). Pass a command (e.g. `bash`) to get a shell in the build environment
|
||||||
|
# instead, useful for debugging a failed build.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||||
|
ROOT="$(pwd)"
|
||||||
|
|
||||||
|
fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
command -v docker >/dev/null 2>&1 \
|
||||||
|
|| fail "docker not found in PATH"
|
||||||
|
|
||||||
|
IMAGE="${HACKED_REGISTRY_IMAGE:-questshock/hacked-builder}"
|
||||||
|
if [ -f "$ROOT/../registry.env" ]; then
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "$ROOT/../registry.env"
|
||||||
|
IMAGE="${HACKED_REGISTRY_IMAGE:-$IMAGE}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
IMAGE_TAG="$(<"$ROOT/hacked-build-image/VERSION")"
|
||||||
|
[ -n "$IMAGE_TAG" ] || fail "hacked-build-image/VERSION is empty"
|
||||||
|
IMAGE="$IMAGE:$IMAGE_TAG"
|
||||||
|
|
||||||
|
docker image inspect "$IMAGE" >/dev/null 2>&1 \
|
||||||
|
|| fail "$IMAGE not found locally - run ./hacked-build-image.sh first"
|
||||||
|
|
||||||
|
TTY_FLAGS="-i"
|
||||||
|
[ -t 1 ] && TTY_FLAGS="-it"
|
||||||
|
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
docker run --rm $TTY_FLAGS \
|
||||||
|
-v "$ROOT:/workspace" \
|
||||||
|
-e HOST_UID="$(id -u)" \
|
||||||
|
-e HOST_GID="$(id -g)" \
|
||||||
|
"$IMAGE" \
|
||||||
|
"$@"
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Pushes the hacked build-image - already built locally with
|
||||||
|
# ./hacked-build-image.sh, and ideally verified with
|
||||||
|
# ./hacked-build-run-image.sh - to a container registry, so CI or other
|
||||||
|
# machines can reuse it without rebuilding the whole toolchain.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||||
|
ROOT="$(pwd)"
|
||||||
|
|
||||||
|
fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
command -v docker >/dev/null 2>&1 \
|
||||||
|
|| fail "docker not found in PATH"
|
||||||
|
|
||||||
|
[ -f "$ROOT/../registry.env" ] \
|
||||||
|
|| fail "../registry.env not found - copy ../registry.env.example to ../registry.env and fill in your registry credentials"
|
||||||
|
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "$ROOT/../registry.env"
|
||||||
|
|
||||||
|
: "${REGISTRY:?registry.env must set REGISTRY}"
|
||||||
|
: "${REGISTRY_USER:?registry.env must set REGISTRY_USER}"
|
||||||
|
: "${REGISTRY_PASSWORD:?registry.env must set REGISTRY_PASSWORD}"
|
||||||
|
HACKED_REGISTRY_IMAGE="${HACKED_REGISTRY_IMAGE:-questshock/hacked-builder}"
|
||||||
|
|
||||||
|
VERSION="$(<"$ROOT/hacked-build-image/VERSION")"
|
||||||
|
[ -n "$VERSION" ] || fail "hacked-build-image/VERSION is empty"
|
||||||
|
|
||||||
|
docker image inspect "$HACKED_REGISTRY_IMAGE:$VERSION" >/dev/null 2>&1 \
|
||||||
|
|| fail "$HACKED_REGISTRY_IMAGE:$VERSION not found locally - run ./hacked-build-image.sh first"
|
||||||
|
|
||||||
|
echo "== Logging in to $REGISTRY =="
|
||||||
|
echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin
|
||||||
|
|
||||||
|
echo "== Pushing $HACKED_REGISTRY_IMAGE:$VERSION and :latest =="
|
||||||
|
docker push "$HACKED_REGISTRY_IMAGE:$VERSION"
|
||||||
|
docker push "$HACKED_REGISTRY_IMAGE:latest"
|
||||||
|
|
||||||
|
echo "== Done =="
|
||||||
|
echo "Image: $HACKED_REGISTRY_IMAGE:$VERSION"
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Builds the mdlp-image (mdlp-image/Dockerfile) locally, tagged with
|
||||||
|
# mdlp-image/VERSION and :latest. Does not push - test it with
|
||||||
|
# ./mdlp-run-image.sh first, then publish with ./mdlp-upload-image.sh.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||||
|
ROOT="$(pwd)"
|
||||||
|
|
||||||
|
fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
command -v docker >/dev/null 2>&1 \
|
||||||
|
|| fail "docker not found in PATH"
|
||||||
|
|
||||||
|
IMAGE="${MDLP_REGISTRY_IMAGE:-questshock/mdlp}"
|
||||||
|
if [ -f "$ROOT/../registry.env" ]; then
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "$ROOT/../registry.env"
|
||||||
|
IMAGE="${MDLP_REGISTRY_IMAGE:-$IMAGE}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
VERSION="$(<"$ROOT/mdlp-image/VERSION")"
|
||||||
|
[ -n "$VERSION" ] || fail "mdlp-image/VERSION is empty"
|
||||||
|
|
||||||
|
echo "== Building $IMAGE:$VERSION =="
|
||||||
|
docker build \
|
||||||
|
-f "$ROOT/mdlp-image/Dockerfile" \
|
||||||
|
-t "$IMAGE:$VERSION" \
|
||||||
|
-t "$IMAGE:latest" \
|
||||||
|
"$ROOT"
|
||||||
|
|
||||||
|
echo "== Done =="
|
||||||
|
echo "Image: $IMAGE:$VERSION (and :latest)"
|
||||||
|
echo "Test it with ./mdlp-run-image.sh, then publish with ./mdlp-upload-image.sh"
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Runtime environment for modding/src/mdlp/ (the first-party Mod Definition
|
||||||
|
# Language Processor - see ../README.md): builds mdlp from source and runs it
|
||||||
|
# against modding/mods/ to produce modding/dist/mods/. Kept as its own image,
|
||||||
|
# separate from hacked-build-image/, because mod processing is expected to
|
||||||
|
# grow its own external tool dependencies (image/audio conversion, etc.)
|
||||||
|
# independent of hacked's own GTK/GLFW/mingw toolchain.
|
||||||
|
FROM ubuntu:22.04
|
||||||
|
|
||||||
|
# go.mod's own "go" directive is a minimum toolchain version, not a version to
|
||||||
|
# fetch - pin a stable release that already satisfies it so `go build` never
|
||||||
|
# needs to fetch a newer toolchain over the network at build time.
|
||||||
|
ARG GO_VERSION=1.27.1
|
||||||
|
|
||||||
|
# patch: applies src/hacked-patches/ to the scratch copy of hacked/ that
|
||||||
|
# process-mods.sh (and ../gen_animgallery.sh) stage before building against
|
||||||
|
# it - see stage-hacked.sh. python3/pip: runs the AI upscaler below via
|
||||||
|
# plain CPU inference (ONNX Runtime). Not a Vulkan-based tool: this image
|
||||||
|
# has no real GPU, and Vulkan software emulation (Mesa lavapipe) gives
|
||||||
|
# wrong colors for ML inference workloads.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates curl gosu patch python3 python3-pip \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
RUN pip3 install --no-cache-dir onnxruntime pillow numpy
|
||||||
|
|
||||||
|
# The AI upscaler model for mdlp's upscale mod type (see ../README.md
|
||||||
|
# section 4): RealESRGAN_x4plus_anime_6B, a 6-block RRDBNet ONNX-exported
|
||||||
|
# from xinntao/Real-ESRGAN's anime-illustration checkpoint (BSD-3-Clause).
|
||||||
|
# It's trained on flat-color line art, so it keeps hard sprite edges rather
|
||||||
|
# than smoothing them the way a photo- or video-denoising model would. A
|
||||||
|
# mod's own "model" field (upscaleDef in ../src/mdlp/upscale.go) is not
|
||||||
|
# consulted; every resource goes through this one model. upscale_batch.py
|
||||||
|
# resizes its fixed native 4x output down to whatever scale a mod actually
|
||||||
|
# asks for.
|
||||||
|
ARG UPSCALE_MODEL_URL=https://huggingface.co/skillsafe-ai/realesrgan-x4plus-anime-6b/resolve/main/model.onnx
|
||||||
|
RUN mkdir -p /opt/upscale-model \
|
||||||
|
&& curl -sSL -o /opt/upscale-model/model.onnx "$UPSCALE_MODEL_URL"
|
||||||
|
COPY mdlp-image/upscale_batch.py /usr/local/bin/upscale_batch.py
|
||||||
|
RUN chmod 755 /usr/local/bin/upscale_batch.py
|
||||||
|
ENV UPSCALE_MODEL_PATH=/opt/upscale-model/model.onnx
|
||||||
|
|
||||||
|
RUN curl -sSLO "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" \
|
||||||
|
&& tar -C /usr/local -xzf "go${GO_VERSION}.linux-amd64.tar.gz" \
|
||||||
|
&& rm "go${GO_VERSION}.linux-amd64.tar.gz"
|
||||||
|
ENV PATH=/usr/local/go/bin:${PATH}
|
||||||
|
ENV GOTOOLCHAIN=local
|
||||||
|
ENV GOPATH=/opt/go
|
||||||
|
|
||||||
|
# Populates /opt/go/pkg/mod with every dependency mdlp/go.mod pins, so the
|
||||||
|
# container never needs network access to build it. mdlp/go.mod replaces
|
||||||
|
# github.com/inkyblackness/hacked with the local ../hacked (see ../README.md),
|
||||||
|
# so that module's own go.mod/go.sum have to be present too for dependency
|
||||||
|
# resolution - only these two go.mod/go.sum pairs are copied in for this, not
|
||||||
|
# the rest of either source tree, so editing .go files doesn't invalidate
|
||||||
|
# this layer's cache.
|
||||||
|
COPY src/hacked/go.mod src/hacked/go.sum /tmp/mdlp-deps/hacked/
|
||||||
|
COPY src/mdlp/go.mod src/mdlp/go.sum /tmp/mdlp-deps/mdlp/
|
||||||
|
RUN cd /tmp/mdlp-deps/mdlp && go mod download && rm -rf /tmp/mdlp-deps
|
||||||
|
|
||||||
|
COPY mdlp-image/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
|
COPY mdlp-image/process-mods.sh /usr/local/bin/process-mods.sh
|
||||||
|
# 755, not +x: see hacked-build-image/Dockerfile's identical comment - the
|
||||||
|
# entrypoint drops to a `builder` user matching the host's UID/GID, which is
|
||||||
|
# never in these root-owned files' group.
|
||||||
|
RUN chmod 755 /usr/local/bin/docker-entrypoint.sh /usr/local/bin/process-mods.sh
|
||||||
|
|
||||||
|
WORKDIR /workspace/modding
|
||||||
|
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||||
|
CMD ["/usr/local/bin/process-mods.sh"]
|
||||||
|
|
||||||
|
# Marks a shell as already running inside this image, same convention as
|
||||||
|
# hacked-build-image/Dockerfile - lets ../Makefile compile directly instead
|
||||||
|
# of trying to docker-run this image again from inside itself.
|
||||||
|
ENV MDLP_IMAGE=1
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
1
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Drops from root (needed to read the Go toolchain and module cache under
|
||||||
|
# /usr/local/go and /opt/go) to a user matching the host's UID/GID, so
|
||||||
|
# anything written into the bind-mounted repo (modding/dist/, ...) is owned
|
||||||
|
# by the host user instead of root.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
USER_UID="${HOST_UID:-1000}"
|
||||||
|
USER_GID="${HOST_GID:-1000}"
|
||||||
|
|
||||||
|
groupadd -g "$USER_GID" builder 2>/dev/null || true
|
||||||
|
useradd -u "$USER_UID" -g "$USER_GID" -m -s /bin/bash builder 2>/dev/null || true
|
||||||
|
|
||||||
|
# /workspace (the bind-mounted repo) may actually be owned by a group the
|
||||||
|
# host user only has via *supplementary* membership rather than their
|
||||||
|
# primary GID above - e.g. a VirtualBox vboxsf shared folder, which shows
|
||||||
|
# up as root:vboxsf on the host and would otherwise be inaccessible to
|
||||||
|
# `builder` here (falls through to "other", which vboxsf's default mode
|
||||||
|
# leaves with no permissions at all). Join whatever group actually owns
|
||||||
|
# /workspace too, if it differs.
|
||||||
|
WORKSPACE_GID="$(stat -c %g /workspace 2>/dev/null || true)"
|
||||||
|
if [ -n "$WORKSPACE_GID" ] && [ "$WORKSPACE_GID" != "$USER_GID" ]; then
|
||||||
|
groupadd -g "$WORKSPACE_GID" workspace 2>/dev/null || true
|
||||||
|
usermod -aG "$WORKSPACE_GID" builder 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The Go module cache under /opt/go was populated as root at image-build
|
||||||
|
# time (see Dockerfile) - readable by anyone, but go build also wants to
|
||||||
|
# write GOCACHE (build cache) under there by default. Point it at a
|
||||||
|
# per-run tmpfs-friendly location inside the builder's own home instead of
|
||||||
|
# fighting permissions on the image's baked-in GOPATH.
|
||||||
|
export GOCACHE=/home/builder/.cache/go-build
|
||||||
|
export GOMODCACHE=/opt/go/pkg/mod
|
||||||
|
|
||||||
|
exec gosu builder "$@"
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Builds modding/src/mdlp/ (the first-party Mod Definition Language
|
||||||
|
# Processor, see ../README.md) and runs it against modding/mods/ to produce
|
||||||
|
# modding/dist/mods/, entirely offline - mdlp's own Go module dependencies
|
||||||
|
# were already fetched into the image at image-build time (see Dockerfile).
|
||||||
|
#
|
||||||
|
# Baked into the image at /usr/local/bin/process-mods.sh (see Dockerfile's
|
||||||
|
# CMD) - run via ../mdlp-run-image.sh, which bind-mounts the whole
|
||||||
|
# repository (not just modding/) at /workspace, since mdlp also needs the
|
||||||
|
# game's own data directory outside modding/.
|
||||||
|
#
|
||||||
|
# Set MODS="name1 name2" (before calling ../mdlp-run-image.sh) to build only
|
||||||
|
# those mods; unset or empty builds every subdirectory under mods/.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
MODDING_ROOT="$(pwd)"
|
||||||
|
REPO_ROOT="$(cd .. && pwd)"
|
||||||
|
|
||||||
|
DATA_DIR="${MDLP_DATA_DIR:-$REPO_ROOT/res/assets/ss_ee/data}"
|
||||||
|
OUT_DIR="$MODDING_ROOT/dist/mods"
|
||||||
|
|
||||||
|
[ -d "$DATA_DIR" ] \
|
||||||
|
|| { echo "Game data not found at $DATA_DIR - see the root README.md section 3." >&2; exit 1; }
|
||||||
|
|
||||||
|
echo "== Staging a patched copy of hacked/ for mdlp to build against =="
|
||||||
|
# mdlp's own go.mod points a `replace` at the sibling ../hacked - stage a
|
||||||
|
# patched copy here (never build src/hacked/ in place) so mdlp picks up the
|
||||||
|
# same fixes hacked's own binary does (see src/hacked-patches/).
|
||||||
|
SCRATCH_DIR="$MODDING_ROOT/build/mdlp-src"
|
||||||
|
rm -rf "$SCRATCH_DIR"
|
||||||
|
mkdir -p "$SCRATCH_DIR"
|
||||||
|
bash "$MODDING_ROOT/mdlp-image/stage-hacked.sh" "$MODDING_ROOT" "$SCRATCH_DIR/hacked"
|
||||||
|
cp -a "$MODDING_ROOT/src/mdlp" "$SCRATCH_DIR/mdlp"
|
||||||
|
|
||||||
|
echo "== Building mdlp =="
|
||||||
|
BIN_DIR="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$BIN_DIR"' EXIT
|
||||||
|
(cd "$SCRATCH_DIR/mdlp" && go build -trimpath -o "$BIN_DIR/mdlp" .)
|
||||||
|
|
||||||
|
MDLP_ARGS=(-data "$DATA_DIR" -mods "$MODDING_ROOT/mods" -out "$OUT_DIR")
|
||||||
|
if [ -n "${MODS:-}" ]; then
|
||||||
|
MDLP_ARGS+=(-only "$MODS")
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== Processing mods =="
|
||||||
|
"$BIN_DIR/mdlp" "${MDLP_ARGS[@]}"
|
||||||
|
|
||||||
|
echo "== Done =="
|
||||||
|
echo "Output: $OUT_DIR"
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Stages a scratch, patched copy of src/hacked/ at the given destination -
|
||||||
|
# shared by process-mods.sh (mdlp) and ../gen_animgallery.sh (animgallery),
|
||||||
|
# both of which build against hacked's source directly (via their own
|
||||||
|
# go.mod's `replace ../hacked`) and so both need the same patches applied
|
||||||
|
# that hacked-build-image/build-hacked.sh already applies for hacked's own
|
||||||
|
# binary. src/hacked/ is never built or patched in place.
|
||||||
|
#
|
||||||
|
# Usage: stage-hacked.sh <modding-root> <dest-dir-for-hacked>
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
MODDING_ROOT="$1"
|
||||||
|
DEST="$2"
|
||||||
|
|
||||||
|
rm -rf "$DEST"
|
||||||
|
mkdir -p "$(dirname "$DEST")"
|
||||||
|
cp -a "$MODDING_ROOT/src/hacked" "$DEST"
|
||||||
|
for p in "$MODDING_ROOT"/src/hacked-patches/*.patch; do
|
||||||
|
patch -p1 -d "$DEST" < "$p"
|
||||||
|
done
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Upscales every PNG in one directory into another, via one ONNX Runtime
|
||||||
|
CPU session shared across the whole batch (same reasoning as mdlp's own
|
||||||
|
Go-side batching: the fixed per-invocation model-load cost otherwise
|
||||||
|
dominates for the many small sprite frames one upscale definition has).
|
||||||
|
|
||||||
|
Runs on CPU only, not Vulkan: this Docker image has no real GPU, and
|
||||||
|
Vulkan software emulation (Mesa lavapipe) gives wrong colors for ML
|
||||||
|
inference workloads.
|
||||||
|
|
||||||
|
Usage: upscale_batch.py <model.onnx> <in-dir> <out-dir> <scale>
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import onnxruntime as ort
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
model_path, in_dir, out_dir, scale_arg = sys.argv[1:5]
|
||||||
|
scale = int(scale_arg)
|
||||||
|
|
||||||
|
session = ort.InferenceSession(model_path, providers=["CPUExecutionProvider"])
|
||||||
|
input_name = session.get_inputs()[0].name
|
||||||
|
|
||||||
|
names = sorted(n for n in os.listdir(in_dir) if n.lower().endswith(".png"))
|
||||||
|
for name in names:
|
||||||
|
img = Image.open(os.path.join(in_dir, name)).convert("RGBA")
|
||||||
|
rgb = img.convert("RGB")
|
||||||
|
alpha = img.split()[3]
|
||||||
|
|
||||||
|
# The model has no concept of transparency (3-channel RGB in/out) -
|
||||||
|
# run it on the flattened RGB only, and scale the alpha mask
|
||||||
|
# separately with a plain resize. Alpha edges don't need AI
|
||||||
|
# enhancement, just to end up the right size.
|
||||||
|
arr = np.asarray(rgb, dtype=np.float32) / 255.0
|
||||||
|
arr = np.transpose(arr, (2, 0, 1))[np.newaxis, ...]
|
||||||
|
output = session.run(None, {input_name: arr})[0]
|
||||||
|
output = np.clip(output[0], 0.0, 1.0) * 255.0
|
||||||
|
output = np.transpose(output, (1, 2, 0)).astype(np.uint8)
|
||||||
|
upscaled_rgb = Image.fromarray(output, "RGB")
|
||||||
|
|
||||||
|
target_size = (img.width * scale, img.height * scale)
|
||||||
|
if upscaled_rgb.size != target_size:
|
||||||
|
# The model upscales at its own fixed native factor - resize to
|
||||||
|
# whatever scale the mod actually asked for.
|
||||||
|
upscaled_rgb = upscaled_rgb.resize(target_size, Image.LANCZOS)
|
||||||
|
upscaled_alpha = alpha.resize(target_size, Image.LANCZOS)
|
||||||
|
|
||||||
|
result = Image.merge("RGBA", (*upscaled_rgb.split(), upscaled_alpha))
|
||||||
|
result.save(os.path.join(out_dir, name))
|
||||||
|
|
||||||
|
print(f"upscaled {len(names)} frame(s)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Runs the mdlp-image to build the mod(s) defined under modding/mods/ (see
|
||||||
|
# README.md) into modding/dist/mods/, entirely offline - every Go module
|
||||||
|
# dependency mdlp needs was already fetched into the image by
|
||||||
|
# ./mdlp-image.sh.
|
||||||
|
#
|
||||||
|
# Mounts the whole repository, not just modding/, because mdlp also needs the
|
||||||
|
# game's own data directory (res/assets/ss_ee/data) as read-only reference
|
||||||
|
# data (see the root README.md section 3 for how that gets populated).
|
||||||
|
#
|
||||||
|
# Usage: MODS="name1 name2" ./mdlp-run-image.sh [command...]
|
||||||
|
# With no arguments, runs the image's baked-in process-mods.sh (its default
|
||||||
|
# CMD), building every mod under mods/ (or only those named in MODS, if
|
||||||
|
# set). Pass a command (e.g. `bash`) to get a shell in the build
|
||||||
|
# environment instead, useful for debugging a failed build.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||||
|
MODDING_ROOT="$(pwd)"
|
||||||
|
REPO_ROOT="$(cd .. && pwd)"
|
||||||
|
|
||||||
|
fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
command -v docker >/dev/null 2>&1 \
|
||||||
|
|| fail "docker not found in PATH"
|
||||||
|
|
||||||
|
IMAGE="${MDLP_REGISTRY_IMAGE:-questshock/mdlp}"
|
||||||
|
if [ -f "$MODDING_ROOT/../registry.env" ]; then
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "$MODDING_ROOT/../registry.env"
|
||||||
|
IMAGE="${MDLP_REGISTRY_IMAGE:-$IMAGE}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
IMAGE_TAG="$(<"$MODDING_ROOT/mdlp-image/VERSION")"
|
||||||
|
[ -n "$IMAGE_TAG" ] || fail "mdlp-image/VERSION is empty"
|
||||||
|
IMAGE="$IMAGE:$IMAGE_TAG"
|
||||||
|
|
||||||
|
docker image inspect "$IMAGE" >/dev/null 2>&1 \
|
||||||
|
|| fail "$IMAGE not found locally - run ./mdlp-image.sh first"
|
||||||
|
|
||||||
|
TTY_FLAGS="-i"
|
||||||
|
[ -t 1 ] && TTY_FLAGS="-it"
|
||||||
|
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
docker run --rm $TTY_FLAGS \
|
||||||
|
-v "$REPO_ROOT:/workspace" \
|
||||||
|
-e HOST_UID="$(id -u)" \
|
||||||
|
-e HOST_GID="$(id -g)" \
|
||||||
|
-e MODS="${MODS:-}" \
|
||||||
|
"$IMAGE" \
|
||||||
|
"$@"
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Pushes the mdlp-image - already built locally with ./mdlp-image.sh, and
|
||||||
|
# ideally verified with ./mdlp-run-image.sh - to a container registry, so CI
|
||||||
|
# or other machines can reuse it without rebuilding the whole toolchain.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||||
|
ROOT="$(pwd)"
|
||||||
|
|
||||||
|
fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
command -v docker >/dev/null 2>&1 \
|
||||||
|
|| fail "docker not found in PATH"
|
||||||
|
|
||||||
|
[ -f "$ROOT/../registry.env" ] \
|
||||||
|
|| fail "../registry.env not found - copy ../registry.env.example to ../registry.env and fill in your registry credentials"
|
||||||
|
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "$ROOT/../registry.env"
|
||||||
|
|
||||||
|
: "${REGISTRY:?registry.env must set REGISTRY}"
|
||||||
|
: "${REGISTRY_USER:?registry.env must set REGISTRY_USER}"
|
||||||
|
: "${REGISTRY_PASSWORD:?registry.env must set REGISTRY_PASSWORD}"
|
||||||
|
MDLP_REGISTRY_IMAGE="${MDLP_REGISTRY_IMAGE:-questshock/mdlp}"
|
||||||
|
|
||||||
|
VERSION="$(<"$ROOT/mdlp-image/VERSION")"
|
||||||
|
[ -n "$VERSION" ] || fail "mdlp-image/VERSION is empty"
|
||||||
|
|
||||||
|
docker image inspect "$MDLP_REGISTRY_IMAGE:$VERSION" >/dev/null 2>&1 \
|
||||||
|
|| fail "$MDLP_REGISTRY_IMAGE:$VERSION not found locally - run ./mdlp-image.sh first"
|
||||||
|
|
||||||
|
echo "== Logging in to $REGISTRY =="
|
||||||
|
echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin
|
||||||
|
|
||||||
|
echo "== Pushing $MDLP_REGISTRY_IMAGE:$VERSION and :latest =="
|
||||||
|
docker push "$MDLP_REGISTRY_IMAGE:$VERSION"
|
||||||
|
docker push "$MDLP_REGISTRY_IMAGE:latest"
|
||||||
|
|
||||||
|
echo "== Done =="
|
||||||
|
echo "Image: $MDLP_REGISTRY_IMAGE:$VERSION"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"type": "Logs",
|
||||||
|
"index": 21,
|
||||||
|
"sender": "Sender: #2-4601",
|
||||||
|
"verboseTextFile": "log-021.txt"
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Looks like Diego's happy with my work... they're firing up the sleep machine for me now. Gotta admit, when the goons from Tri-Op caught me I thought for sure they'd take me off line. Instead, Diego just asks for a "favor." Hack him into SHODAN, and all is forgiven. Plus, six months in a healing coma earns me a cyberjack interface even TriOp's execs couldn't swing. I'll be king of the Net. Even so, I have just handed the most powerful AI in the system to a fumbling corp VP, and there's no telling what'll happen. They tell me the coma leaves you foggy, so I'll leave myself some reminders. First off, the combo to the healing suite is 451. Second, I've stashed some useful stuff in the maintenance hatch under the grating "north" of the healing suite. Last and best, I finished the system analyzer which'll let me keep an eye on Shodan's processes. It's in the storage closet outside the sleep machine. In ten minutes it's off to bed for half a year. Good night. Questshock Mod Version: 0.0.1
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"type": "Mails",
|
||||||
|
"index": 26,
|
||||||
|
"sender": "Rebecca Lansing1",
|
||||||
|
"title": "Rebecca-001",
|
||||||
|
"verboseTextFile": "mail-026.txt"
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Listen to me very carefully HACKER. Something has gone terribly wrong on Citadel station. You might be the last survivor. Communications are being jammed. We know that something on board is attempting to harm humanity. The mining laser is being charged for a possible strike against Earth populations. On your level is the office of a Dr. D'Arcy, who may know something about the laser. The office is located near the central hub, on a balcony, with the medical icon clearly visible. When the laser is destroyed, you will have to reach the bridge and stop whatever is responsible for the catastrophe. We believe that the biological labs and security systems have gone haywire, so be careful. If you pull this off, we will be able to get you out alive. Good luck.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"model": "realesrgan-x4plus-anime",
|
||||||
|
"scale": 2,
|
||||||
|
"resources": [
|
||||||
|
"0x583",
|
||||||
|
"0x5CD",
|
||||||
|
"0x617",
|
||||||
|
"0x689",
|
||||||
|
"0x68A",
|
||||||
|
"0x68B",
|
||||||
|
"0x68C",
|
||||||
|
"0x68D",
|
||||||
|
"0x68E",
|
||||||
|
"0x68F",
|
||||||
|
"0x690",
|
||||||
|
"0x7B0",
|
||||||
|
"0x7B1",
|
||||||
|
"0x7B2",
|
||||||
|
"0x7B3",
|
||||||
|
"0x7B4",
|
||||||
|
"0x7B5",
|
||||||
|
"0x7B6",
|
||||||
|
"0x7B7",
|
||||||
|
"0x84C"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"model": "realesrgan-x4plus-anime",
|
||||||
|
"scale": 2,
|
||||||
|
"resources": [
|
||||||
|
"0x580",
|
||||||
|
"0x5A5",
|
||||||
|
"0x5CA",
|
||||||
|
"0x5EF",
|
||||||
|
"0x614",
|
||||||
|
"0x671",
|
||||||
|
"0x672",
|
||||||
|
"0x673",
|
||||||
|
"0x674",
|
||||||
|
"0x675",
|
||||||
|
"0x676",
|
||||||
|
"0x677",
|
||||||
|
"0x678",
|
||||||
|
"0x798",
|
||||||
|
"0x799",
|
||||||
|
"0x79A",
|
||||||
|
"0x79B",
|
||||||
|
"0x79C",
|
||||||
|
"0x79D",
|
||||||
|
"0x79E",
|
||||||
|
"0x79F",
|
||||||
|
"0x849"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"model": "realesrgan-x4plus-anime",
|
||||||
|
"scale": 2,
|
||||||
|
"resources": [
|
||||||
|
"0x578",
|
||||||
|
"0x59D",
|
||||||
|
"0x5C2",
|
||||||
|
"0x5E7",
|
||||||
|
"0x60C",
|
||||||
|
"0x631",
|
||||||
|
"0x632",
|
||||||
|
"0x633",
|
||||||
|
"0x634",
|
||||||
|
"0x635",
|
||||||
|
"0x636",
|
||||||
|
"0x637",
|
||||||
|
"0x638",
|
||||||
|
"0x661",
|
||||||
|
"0x662",
|
||||||
|
"0x663",
|
||||||
|
"0x664",
|
||||||
|
"0x665",
|
||||||
|
"0x666",
|
||||||
|
"0x667",
|
||||||
|
"0x668",
|
||||||
|
"0x758",
|
||||||
|
"0x759",
|
||||||
|
"0x75A",
|
||||||
|
"0x75B",
|
||||||
|
"0x75C",
|
||||||
|
"0x75D",
|
||||||
|
"0x75E",
|
||||||
|
"0x75F",
|
||||||
|
"0x841"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
module questshock/animgallery
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
// hacked/ is vendored locally (see ../../README.md); animgallery reuses its
|
||||||
|
// resource decoding code (ss1/world, ss1/content/bitmap) instead of
|
||||||
|
// duplicating System Shock's file formats.
|
||||||
|
require github.com/inkyblackness/hacked v0.0.0
|
||||||
|
|
||||||
|
require github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||||
|
|
||||||
|
replace github.com/inkyblackness/hacked => ../hacked
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
||||||
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
|
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
|
||||||
|
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
@@ -0,0 +1,595 @@
|
|||||||
|
// Command animgallery scans a game data directory for every multi-frame
|
||||||
|
// bitmap resource (both dedicated "Animation" descriptors, e.g. video mail,
|
||||||
|
// and plain multi-block compound bitmaps, e.g. critter sprites - System
|
||||||
|
// Shock uses both conventions) and renders each one as an animated GIF plus
|
||||||
|
// an HTML index page, so they can be browsed by eye instead of guessed at
|
||||||
|
// by resource ID.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/gif"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/inkyblackness/hacked/ss1/content/bitmap"
|
||||||
|
"github.com/inkyblackness/hacked/ss1/resource"
|
||||||
|
"github.com/inkyblackness/hacked/ss1/world"
|
||||||
|
"github.com/inkyblackness/hacked/ss1/world/ids"
|
||||||
|
)
|
||||||
|
|
||||||
|
// galleryEntry describes one rendered animation for the HTML index.
|
||||||
|
type galleryEntry struct {
|
||||||
|
id resource.ID
|
||||||
|
lang resource.Language
|
||||||
|
sourceFile string
|
||||||
|
kind string
|
||||||
|
frameCount int
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
transparent bool
|
||||||
|
gifName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// dataDirs collects repeated -data flags, in the order given - later ones
|
||||||
|
// take priority over earlier ones for any resource ID they both define,
|
||||||
|
// same as System Shock's own overlay convention. This lets a mod's build
|
||||||
|
// output (modding/dist/mods/<name>/mod/) be layered on top of the base
|
||||||
|
// game assets to preview the combined, as-played result.
|
||||||
|
type dataDirs []string
|
||||||
|
|
||||||
|
func (d *dataDirs) String() string { return strings.Join(*d, ",") }
|
||||||
|
func (d *dataDirs) Set(v string) error { *d = append(*d, v); return nil }
|
||||||
|
|
||||||
|
// loadDataDir merges every .res/.dat file directly inside dir into one
|
||||||
|
// manifest entry, bypassing hacked's own fileAllowlist
|
||||||
|
// (ss1/world/FileLoader.go): that check is only skipped when a file is
|
||||||
|
// passed directly as the sole argument, not when given a directory to
|
||||||
|
// scan, so a directory containing a resource file with a name it doesn't
|
||||||
|
// recognize (like mdlp's own "unknown.res" fallback - see
|
||||||
|
// [[project_modding_hacked_build]] memory) is otherwise silently treated
|
||||||
|
// as if it had no resources at all. The real game engine has no such
|
||||||
|
// restriction - it matches any .res/.dat file by extension alone
|
||||||
|
// (engine/src/MacSrc/Modding.c's StringEndsWith), which this mirrors.
|
||||||
|
func loadDataDir(dir string) (*world.ManifestEntry, error) {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
merged := &world.ManifestEntry{Origin: []string{dir}, ID: dir}
|
||||||
|
found := false
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ext := strings.ToLower(filepath.Ext(e.Name()))
|
||||||
|
if ext != ".res" && ext != ".dat" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fileEntry, err := world.NewManifestEntryFrom([]string{filepath.Join(dir, e.Name())})
|
||||||
|
if err != nil {
|
||||||
|
// Not every .res/.dat file is actually resource-formatted (e.g.
|
||||||
|
// ambrtabl.dat is a raw lookup table) - hacked's own
|
||||||
|
// fileAllowlist silently protects against exactly this for
|
||||||
|
// canonical filenames, but bypassing it here (the whole point,
|
||||||
|
// for names like "unknown.res" it doesn't recognize) means we
|
||||||
|
// have to tolerate that ourselves instead: skip a file that
|
||||||
|
// doesn't parse rather than failing the whole directory over it.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
merged.Resources = append(merged.Resources, fileEntry.Resources...)
|
||||||
|
if len(fileEntry.ObjectProperties) > 0 {
|
||||||
|
merged.ObjectProperties = fileEntry.ObjectProperties
|
||||||
|
}
|
||||||
|
if len(fileEntry.TextureProperties) > 0 {
|
||||||
|
merged.TextureProperties = fileEntry.TextureProperties
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return nil, fmt.Errorf("no .res/.dat files found in %s", dir)
|
||||||
|
}
|
||||||
|
return merged, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var dirs dataDirs
|
||||||
|
flag.Var(&dirs, "data", "directory with .res/.dat files - repeat to layer several, later wins (e.g. base game assets, then a built mod's mod/ folder)")
|
||||||
|
outDir := flag.String("out", "gallery", "directory to write index.html and every .gif into")
|
||||||
|
minFrames := flag.Int("min-frames", 2, "skip plain multi-block bitmaps with fewer blocks than this (Animation-type resources are always included)")
|
||||||
|
onlyChanged := flag.Bool("only-changed", false, "skip resources whose winning -data layer is the first one - pass a base layer followed by one or more mod layers to preview only what those later layers change, in the base layer's own colors")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if len(dirs) == 0 {
|
||||||
|
log.Fatal("-data is required (repeat it to layer several directories)")
|
||||||
|
}
|
||||||
|
absOutDir, err := filepath.Abs(*outDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("resolving -out: %v", err)
|
||||||
|
}
|
||||||
|
gifDir := filepath.Join(absOutDir, "gifs")
|
||||||
|
if err := os.MkdirAll(gifDir, 0o755); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mod := world.NewMod(func([]resource.ID, []resource.ID) {}, func() {})
|
||||||
|
// sourceFileByKey records, per (language, id), which directory's
|
||||||
|
// resource "wins" after layering - the same last-one-wins rule
|
||||||
|
// Manifest.Filter itself uses for entries inserted in this order.
|
||||||
|
// sourceDirIndexByKey records the same winner as an index into dirs,
|
||||||
|
// for -only-changed to tell a first-layer (base) resource from one a
|
||||||
|
// later layer (a mod) overrides.
|
||||||
|
sourceFileByKey := make(map[resource.Key]string)
|
||||||
|
sourceDirIndexByKey := make(map[resource.Key]int)
|
||||||
|
for dirIndex, dir := range dirs {
|
||||||
|
absDir, err := filepath.Abs(dir)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("resolving -data %q: %v", dir, err)
|
||||||
|
}
|
||||||
|
entry, err := loadDataDir(absDir)
|
||||||
|
if err != nil {
|
||||||
|
// A mod that doesn't touch any resource file yet (e.g. an
|
||||||
|
// upscale mod before mdlp supports that content type) produces
|
||||||
|
// an empty build directory - skip it rather than aborting the
|
||||||
|
// whole layered scan over one contributing -data directory.
|
||||||
|
log.Printf("skipping -data %s: %v", absDir, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// NewManifestEntryFrom builds entry.Resources by ranging over a Go
|
||||||
|
// map, so its order (and therefore which file "wins" a Selector's
|
||||||
|
// last-one-wins tie-break for any resource ID more than one file
|
||||||
|
// defines) is randomized per process run. Sort it ourselves so the
|
||||||
|
// same input always resolves the same way.
|
||||||
|
sort.Slice(entry.Resources, func(i, j int) bool { return entry.Resources[i].ID < entry.Resources[j].ID })
|
||||||
|
if err := mod.World().InsertEntry(mod.World().EntryCount(), entry); err != nil {
|
||||||
|
log.Fatalf("registering game data from %s: %v", absDir, err)
|
||||||
|
}
|
||||||
|
for _, loc := range entry.Resources {
|
||||||
|
for _, id := range loc.Viewer.IDs() {
|
||||||
|
key := resource.KeyOf(id, loc.Language, 0)
|
||||||
|
sourceFileByKey[key] = fmt.Sprintf("%s (%s)", loc.ID, filepath.Base(absDir))
|
||||||
|
sourceDirIndexByKey[key] = dirIndex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
palCache := bitmap.NewPaletteCache(mod)
|
||||||
|
defaultPal, err := palCache.Palette(resource.KeyOf(ids.GamePalettesStart, resource.LangDefault, 0))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("warning: could not load the default game palette (%v); falling back to grayscale", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := make([]resource.Key, 0, len(sourceFileByKey))
|
||||||
|
for key := range sourceFileByKey {
|
||||||
|
if *onlyChanged && sourceDirIndexByKey[key] == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Slice(keys, func(i, j int) bool {
|
||||||
|
if keys[i].ID != keys[j].ID {
|
||||||
|
return keys[i].ID < keys[j].ID
|
||||||
|
}
|
||||||
|
return keys[i].Lang < keys[j].Lang
|
||||||
|
})
|
||||||
|
|
||||||
|
var entries []galleryEntry
|
||||||
|
for _, key := range keys {
|
||||||
|
sourceFile := sourceFileByKey[key]
|
||||||
|
// Re-resolved through the layered mod, not the raw per-directory
|
||||||
|
// viewer, so an overriding mod's version wins here exactly like it
|
||||||
|
// would for the running game.
|
||||||
|
view, err := mod.LocalizedResources(key.Lang).Select(key.ID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case view.ContentType() == resource.Animation:
|
||||||
|
entry, err := safeRenderAnimationDescriptor(mod, defaultPal, sourceFile, key.ID, key.Lang, gifDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("skipping animation %s (%s): %v", key.ID, sourceFile, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entries = append(entries, entry)
|
||||||
|
|
||||||
|
case view.ContentType() == resource.Bitmap && view.BlockCount() >= *minFrames:
|
||||||
|
entry, err := safeRenderMultiBlockBitmap(view, defaultPal, sourceFile, key.ID, key.Lang, gifDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("skipping bitmap %s (%s): %v", key.ID, sourceFile, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entries = append(entries, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(entries, func(i, j int) bool {
|
||||||
|
if entries[i].id != entries[j].id {
|
||||||
|
return entries[i].id < entries[j].id
|
||||||
|
}
|
||||||
|
return entries[i].lang < entries[j].lang
|
||||||
|
})
|
||||||
|
|
||||||
|
indexPath := filepath.Join(absOutDir, "index.html")
|
||||||
|
if err := writeIndex(indexPath, entries); err != nil {
|
||||||
|
log.Fatalf("writing %s: %v", indexPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Found %d animated resource(s).\n", len(entries))
|
||||||
|
fmt.Printf("Index: %s\n", indexPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeFrames reads every block of a compound bitmap resource as a
|
||||||
|
// sequence of frames, each one RLE-diffed against the previous - the same
|
||||||
|
// chaining hacked's own editor/animations/View.go uses when importing a
|
||||||
|
// GIF, done here in reverse (decode instead of encode).
|
||||||
|
func decodeFrames(view resource.View) ([]*bitmap.Bitmap, error) {
|
||||||
|
count := view.BlockCount()
|
||||||
|
frames := make([]*bitmap.Bitmap, 0, count)
|
||||||
|
var prevPixels []byte
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
reader, err := view.Block(i)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("block %d: %w", i, err)
|
||||||
|
}
|
||||||
|
bmp, err := bitmap.DecodeReferenced(reader, func(w, h int16) ([]byte, error) {
|
||||||
|
size := int(w) * int(h)
|
||||||
|
buf := make([]byte, size)
|
||||||
|
if len(prevPixels) == size {
|
||||||
|
copy(buf, prevPixels)
|
||||||
|
}
|
||||||
|
return buf, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("block %d: %w", i, err)
|
||||||
|
}
|
||||||
|
prevPixels = bmp.Pixels
|
||||||
|
frames = append(frames, bmp)
|
||||||
|
}
|
||||||
|
return frames, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func paletteFor(bmp *bitmap.Bitmap, fallback bitmap.Palette) color.Palette {
|
||||||
|
pal := fallback
|
||||||
|
if bmp.Palette != nil {
|
||||||
|
pal = *bmp.Palette
|
||||||
|
}
|
||||||
|
return pal.ColorPalette(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeGIF(path string, frames []*bitmap.Bitmap, fallbackPal bitmap.Palette, delays []int) error {
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
maxWidth, maxHeight := 0, 0
|
||||||
|
for _, bmp := range frames {
|
||||||
|
if int(bmp.Header.Width) > maxWidth {
|
||||||
|
maxWidth = int(bmp.Header.Width)
|
||||||
|
}
|
||||||
|
if int(bmp.Header.Height) > maxHeight {
|
||||||
|
maxHeight = int(bmp.Header.Height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoopCount: 0 means loop forever in Go's image/gif API - inverted from
|
||||||
|
// the raw GIF format's own NETSCAPE2.0 convention. -1 (this package's
|
||||||
|
// zero value) means play once, which is why these looped only once
|
||||||
|
// before this fix.
|
||||||
|
out := gif.GIF{LoopCount: 0, Config: image.Config{Width: maxWidth, Height: maxHeight}}
|
||||||
|
for i, bmp := range frames {
|
||||||
|
colorPal := paletteFor(bmp, fallbackPal)
|
||||||
|
rect := image.Rect(0, 0, int(bmp.Header.Width), int(bmp.Header.Height))
|
||||||
|
img := image.NewPaletted(rect, colorPal)
|
||||||
|
if len(bmp.Pixels) == len(img.Pix) {
|
||||||
|
copy(img.Pix, bmp.Pixels)
|
||||||
|
}
|
||||||
|
out.Image = append(out.Image, img)
|
||||||
|
out.Delay = append(out.Delay, delays[i])
|
||||||
|
}
|
||||||
|
return gif.EncodeAll(f, &out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeRenderMultiBlockBitmap recovers from panics in hacked's own decode
|
||||||
|
// code (malformed or non-image compound resources can crash rle.Decompress
|
||||||
|
// rather than return an error) so one bad resource never aborts a scan that
|
||||||
|
// is meant to be re-run repeatedly during modding.
|
||||||
|
func safeRenderMultiBlockBitmap(view resource.View, fallbackPal bitmap.Palette, sourceFile string, id resource.ID, lang resource.Language, outDir string) (entry galleryEntry, err error) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
err = fmt.Errorf("panic: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return renderMultiBlockBitmap(view, fallbackPal, sourceFile, id, lang, outDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderMultiBlockBitmap(view resource.View, fallbackPal bitmap.Palette, sourceFile string, id resource.ID, lang resource.Language, outDir string) (galleryEntry, error) {
|
||||||
|
frames, err := decodeFrames(view)
|
||||||
|
if err != nil {
|
||||||
|
return galleryEntry{}, err
|
||||||
|
}
|
||||||
|
if len(frames) == 0 {
|
||||||
|
return galleryEntry{}, fmt.Errorf("no frames")
|
||||||
|
}
|
||||||
|
delays := make([]int, len(frames))
|
||||||
|
for i := range delays {
|
||||||
|
delays[i] = 10 // 100ms - no explicit timing exists for this raw form
|
||||||
|
}
|
||||||
|
gifName := fmt.Sprintf("%04X_%s.gif", int(id), lang.String())
|
||||||
|
if err := writeGIF(filepath.Join(outDir, gifName), frames, fallbackPal, delays); err != nil {
|
||||||
|
return galleryEntry{}, err
|
||||||
|
}
|
||||||
|
return galleryEntry{
|
||||||
|
id: id, lang: lang, sourceFile: sourceFile, kind: "multi-block bitmap",
|
||||||
|
frameCount: len(frames),
|
||||||
|
width: int(frames[0].Header.Width), height: int(frames[0].Header.Height),
|
||||||
|
transparent: frames[0].Header.Flags&bitmap.FlagTransparent != 0,
|
||||||
|
gifName: gifName,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeRenderAnimationDescriptor is safeRenderMultiBlockBitmap's counterpart
|
||||||
|
// for the Animation-descriptor path.
|
||||||
|
func safeRenderAnimationDescriptor(mod *world.Mod, fallbackPal bitmap.Palette, sourceFile string, id resource.ID, lang resource.Language, outDir string) (entry galleryEntry, err error) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
err = fmt.Errorf("panic: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return renderAnimationDescriptor(mod, fallbackPal, sourceFile, id, lang, outDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderAnimationDescriptor(mod *world.Mod, fallbackPal bitmap.Palette, sourceFile string, id resource.ID, lang resource.Language, outDir string) (galleryEntry, error) {
|
||||||
|
view, err := mod.LocalizedResources(lang).Select(id)
|
||||||
|
if err != nil {
|
||||||
|
return galleryEntry{}, err
|
||||||
|
}
|
||||||
|
reader, err := view.Block(0)
|
||||||
|
if err != nil {
|
||||||
|
return galleryEntry{}, err
|
||||||
|
}
|
||||||
|
anim, err := bitmap.ReadAnimation(reader)
|
||||||
|
if err != nil {
|
||||||
|
return galleryEntry{}, err
|
||||||
|
}
|
||||||
|
if len(anim.Entries) == 0 {
|
||||||
|
return galleryEntry{}, fmt.Errorf("no animation entries")
|
||||||
|
}
|
||||||
|
|
||||||
|
lastFrame := 0
|
||||||
|
for _, entry := range anim.Entries {
|
||||||
|
if int(entry.LastFrame) > lastFrame {
|
||||||
|
lastFrame = int(entry.LastFrame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var frames []*bitmap.Bitmap
|
||||||
|
var delays []int
|
||||||
|
var prevPixels []byte
|
||||||
|
frameIndex := 0
|
||||||
|
for _, entry := range anim.Entries {
|
||||||
|
for frameIndex <= int(entry.LastFrame) {
|
||||||
|
frameKey := resource.KeyOf(anim.ResourceID, lang, frameIndex)
|
||||||
|
frameView, err := mod.LocalizedResources(lang).Select(frameKey.ID)
|
||||||
|
if err != nil {
|
||||||
|
return galleryEntry{}, fmt.Errorf("frame %d: %w", frameIndex, err)
|
||||||
|
}
|
||||||
|
reader, err := frameView.Block(0)
|
||||||
|
if err != nil {
|
||||||
|
return galleryEntry{}, fmt.Errorf("frame %d: %w", frameIndex, err)
|
||||||
|
}
|
||||||
|
bmp, err := bitmap.DecodeReferenced(reader, func(w, h int16) ([]byte, error) {
|
||||||
|
size := int(w) * int(h)
|
||||||
|
buf := make([]byte, size)
|
||||||
|
if len(prevPixels) == size {
|
||||||
|
copy(buf, prevPixels)
|
||||||
|
}
|
||||||
|
return buf, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return galleryEntry{}, fmt.Errorf("frame %d: %w", frameIndex, err)
|
||||||
|
}
|
||||||
|
prevPixels = bmp.Pixels
|
||||||
|
frames = append(frames, bmp)
|
||||||
|
delay := int(entry.FrameTime) / 10
|
||||||
|
if delay <= 0 {
|
||||||
|
delay = 10
|
||||||
|
}
|
||||||
|
delays = append(delays, delay)
|
||||||
|
frameIndex++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(frames) == 0 {
|
||||||
|
return galleryEntry{}, fmt.Errorf("no frames decoded")
|
||||||
|
}
|
||||||
|
|
||||||
|
gifName := fmt.Sprintf("%04X_%s.gif", int(id), lang.String())
|
||||||
|
if err := writeGIF(filepath.Join(outDir, gifName), frames, fallbackPal, delays); err != nil {
|
||||||
|
return galleryEntry{}, err
|
||||||
|
}
|
||||||
|
return galleryEntry{
|
||||||
|
id: id, lang: lang, sourceFile: sourceFile, kind: "animation descriptor",
|
||||||
|
frameCount: len(frames),
|
||||||
|
width: int(anim.Width), height: int(anim.Height),
|
||||||
|
transparent: frames[0].Header.Flags&bitmap.FlagTransparent != 0,
|
||||||
|
gifName: gifName,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// contentTypeName maps a galleryEntry.kind back to the resource.ContentType
|
||||||
|
// name mdlp will need (its own decode path differs: an "Animation"
|
||||||
|
// descriptor's frames live at anim.ResourceID+N, a "Bitmap"'s frames are
|
||||||
|
// just its own blocks) - see kind's assignment in renderAnimationDescriptor/
|
||||||
|
// renderMultiBlockBitmap.
|
||||||
|
func contentTypeName(kind string) string {
|
||||||
|
if kind == "animation descriptor" {
|
||||||
|
return "Animation"
|
||||||
|
}
|
||||||
|
return "Bitmap"
|
||||||
|
}
|
||||||
|
|
||||||
|
// mdlResourceID formats the resourceId string an MDL upscale definition
|
||||||
|
// uses to identify one resource: a resource ID alone is not always unique -
|
||||||
|
// e.g. 0x25 exists as a separate resource per language - so a non-Any
|
||||||
|
// language is appended (0x25_German). The bare form (0xA54) implies
|
||||||
|
// _Any, the language nearly everything we care about upscaling (critter
|
||||||
|
// art, etc.) actually uses.
|
||||||
|
func mdlResourceID(id resource.ID, lang resource.Language) string {
|
||||||
|
if lang == resource.LangAny {
|
||||||
|
return fmt.Sprintf("0x%X", int(id))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("0x%X_%s", int(id), lang.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeIndex(path string, entries []galleryEntry) error {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("<!doctype html><html><head><meta charset=\"utf-8\"><title>System Shock animation gallery</title>")
|
||||||
|
b.WriteString("<style>body{font-family:sans-serif;background:#111;color:#eee}")
|
||||||
|
b.WriteString("#controls{position:sticky;top:0;background:#181818;padding:10px;z-index:10;border-bottom:1px solid #444}")
|
||||||
|
b.WriteString("#controls label{margin-right:12px}")
|
||||||
|
b.WriteString("#mdl-output{width:98%;font-family:monospace;font-size:12px;margin-top:8px;background:#000;color:#9f9;border:1px solid #444}")
|
||||||
|
b.WriteString(".card{display:inline-block;margin:8px;padding:8px;background:#222;vertical-align:top}")
|
||||||
|
b.WriteString(".card img{image-rendering:pixelated;background:#000;display:block}")
|
||||||
|
b.WriteString(".meta{font-size:12px;color:#aaa;margin-top:4px}")
|
||||||
|
b.WriteString(".meta label{cursor:pointer}")
|
||||||
|
b.WriteString("#basket{min-height:60px;background:#1a2a1a;border:1px dashed #4a6a4a;padding:8px;margin-bottom:16px}")
|
||||||
|
b.WriteString("#basket:empty::before{content:'(nothing selected yet - check animations below to add them here)';color:#678;font-style:italic}")
|
||||||
|
b.WriteString("#basket .card{background:#2a3a2a}</style></head><body>")
|
||||||
|
|
||||||
|
b.WriteString(`<div id="controls">` +
|
||||||
|
`<label>Model: <select id="model">` +
|
||||||
|
`<option>realesrgan-x4plus-anime</option>` +
|
||||||
|
`<option>realesrgan-x4plus</option>` +
|
||||||
|
`<option>realesr-animevideov3</option>` +
|
||||||
|
`</select></label>` +
|
||||||
|
`<label>Scale: <input id="scale" type="number" value="2" min="1" max="4" style="width:4em"></label>` +
|
||||||
|
`<button id="clear-sel" type="button">Clear selection</button>` +
|
||||||
|
`<br><label>Filter: <input type="text" id="search" placeholder="id, language, file, transparent..." style="width:22em"></label>` +
|
||||||
|
`<span id="shown-count"></span>` +
|
||||||
|
`<textarea id="mdl-output" rows="12" readonly placeholder="Select animations below to generate an MDL upscale definition here - copy it into modding/mods/<name>/upscale/<label>.json"></textarea>` +
|
||||||
|
`</div>`)
|
||||||
|
|
||||||
|
b.WriteString(`<h2>Selected (<span id="basket-count">0</span>)</h2><div id="basket"></div>`)
|
||||||
|
|
||||||
|
fmt.Fprintf(&b, "<h1>%d animated resource(s)</h1>", len(entries))
|
||||||
|
|
||||||
|
// Animation descriptors and multi-block bitmaps are genuinely distinct
|
||||||
|
// content types (different decode path, see contentTypeName) - grouped
|
||||||
|
// into separate sections rather than interleaved.
|
||||||
|
var animations, bitmaps []galleryEntry
|
||||||
|
for _, e := range entries {
|
||||||
|
if contentTypeName(e.kind) == "Animation" {
|
||||||
|
animations = append(animations, e)
|
||||||
|
} else {
|
||||||
|
bitmaps = append(bitmaps, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeCards := func(group []galleryEntry, idPrefix string) {
|
||||||
|
for i, e := range group {
|
||||||
|
transparency := "opaque"
|
||||||
|
if e.transparent {
|
||||||
|
transparency = "transparent"
|
||||||
|
}
|
||||||
|
search := strings.ToLower(strings.Join([]string{
|
||||||
|
fmt.Sprintf("0x%x", int(e.id)), fmt.Sprintf("%d", int(e.id)),
|
||||||
|
e.lang.String(), e.kind, e.sourceFile,
|
||||||
|
fmt.Sprintf("%dx%d", e.width, e.height), transparency,
|
||||||
|
}, " "))
|
||||||
|
fmt.Fprintf(&b, "<div class=\"card\" data-search=\"%s\">", html.EscapeString(search))
|
||||||
|
fmt.Fprintf(&b, "<img src=\"gifs/%s\" width=\"%d\" height=\"%d\">", html.EscapeString(e.gifName), e.width*2, e.height*2)
|
||||||
|
fmt.Fprintf(&b, "<div class=\"meta\"><label><input type=\"checkbox\" class=\"sel\" id=\"sel-%s-%d\" data-id=\"%s\"> ",
|
||||||
|
idPrefix, i, html.EscapeString(mdlResourceID(e.id, e.lang)))
|
||||||
|
fmt.Fprintf(&b, "ID 0x%X (%d) · %s</label><br>%dx%d, %d frame(s), %s · %s · %s</div>",
|
||||||
|
int(e.id), int(e.id), html.EscapeString(e.lang.String()),
|
||||||
|
e.width, e.height, e.frameCount, transparency, html.EscapeString(e.kind), html.EscapeString(e.sourceFile))
|
||||||
|
b.WriteString("</div>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "<h2>Animations (%d)</h2>", len(animations))
|
||||||
|
writeCards(animations, "a")
|
||||||
|
fmt.Fprintf(&b, "<h2>Bitmaps (%d)</h2>", len(bitmaps))
|
||||||
|
writeCards(bitmaps, "b")
|
||||||
|
|
||||||
|
b.WriteString(`<script>
|
||||||
|
var basket = document.getElementById('basket');
|
||||||
|
// Where each card lives when not selected, so unchecking it puts it back
|
||||||
|
// in its original spot (Animations/Bitmaps section, original order)
|
||||||
|
// instead of just appending it back at the end.
|
||||||
|
var cardHomes = new Map();
|
||||||
|
Array.prototype.forEach.call(document.querySelectorAll('.card'), function (card) {
|
||||||
|
cardHomes.set(card, { parent: card.parentNode, next: card.nextSibling });
|
||||||
|
});
|
||||||
|
|
||||||
|
function moveCard(checkbox) {
|
||||||
|
var card = checkbox.closest('.card');
|
||||||
|
if (checkbox.checked) {
|
||||||
|
basket.appendChild(card);
|
||||||
|
} else {
|
||||||
|
var home = cardHomes.get(card);
|
||||||
|
home.parent.insertBefore(card, home.next);
|
||||||
|
}
|
||||||
|
document.getElementById('basket-count').textContent = basket.children.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMdl() {
|
||||||
|
var model = document.getElementById('model').value;
|
||||||
|
var scale = parseInt(document.getElementById('scale').value, 10) || 2;
|
||||||
|
var boxes = Array.prototype.filter.call(document.querySelectorAll('.sel'), function (b) { return b.checked; });
|
||||||
|
// A bare resource ID isn't always unique (e.g. 0x25 exists as a separate
|
||||||
|
// resource per language), so each entry here is "0x<ID>" or, when the
|
||||||
|
// resource's language isn't Any, "0x<ID>_<Language>" - mdlp resolves the
|
||||||
|
// rest (content type, etc.) from the resource itself. The MDL
|
||||||
|
// definition only needs what it can't derive: the shared upscale
|
||||||
|
// parameters, and which resources to apply them to.
|
||||||
|
var mdl = {
|
||||||
|
model: model,
|
||||||
|
scale: scale,
|
||||||
|
resources: boxes.map(function (b) { return b.dataset.id; })
|
||||||
|
};
|
||||||
|
document.getElementById('mdl-output').value = JSON.stringify(mdl, null, 2);
|
||||||
|
}
|
||||||
|
Array.prototype.forEach.call(document.querySelectorAll('.sel'), function (b) {
|
||||||
|
b.addEventListener('change', function () { moveCard(b); updateMdl(); });
|
||||||
|
});
|
||||||
|
document.getElementById('model').addEventListener('change', updateMdl);
|
||||||
|
document.getElementById('scale').addEventListener('input', updateMdl);
|
||||||
|
document.getElementById('clear-sel').addEventListener('click', function () {
|
||||||
|
Array.prototype.forEach.call(document.querySelectorAll('.sel'), function (b) {
|
||||||
|
if (b.checked) {
|
||||||
|
b.checked = false;
|
||||||
|
moveCard(b);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
updateMdl();
|
||||||
|
});
|
||||||
|
updateMdl();
|
||||||
|
|
||||||
|
function applyFilter() {
|
||||||
|
var query = document.getElementById('search').value.trim().toLowerCase();
|
||||||
|
var cards = document.querySelectorAll('.card');
|
||||||
|
var shown = 0;
|
||||||
|
Array.prototype.forEach.call(cards, function (card) {
|
||||||
|
var match = !query || card.dataset.search.indexOf(query) !== -1;
|
||||||
|
card.style.display = match ? '' : 'none';
|
||||||
|
if (match) shown++;
|
||||||
|
});
|
||||||
|
document.getElementById('shown-count').textContent = shown + ' / ' + cards.length + ' shown';
|
||||||
|
}
|
||||||
|
document.getElementById('search').addEventListener('input', applyFilter);
|
||||||
|
applyFilter();
|
||||||
|
</script>`)
|
||||||
|
|
||||||
|
b.WriteString("</body></html>")
|
||||||
|
return os.WriteFile(path, []byte(b.String()), 0o644)
|
||||||
|
}
|
||||||