26 Commits

Author SHA1 Message Date
ml 345f8b5f17 Group animgallery cards by mod and add group quick filters
build / build (push) Successful in 2m49s
2026-10-27 08:27:59 +01:00
ml 90303894ce Fix animgallery rendering bugs and mitigate AI-upscale frame flicker
build / build (push) Successful in 2m52s
- writeGIF now pads every frame to one shared canvas instead of
  writing each at its own (possibly storage-cropped) size - GIF
  disposal rules were showing a previous frame's stale pixels through
  the uncovered border of a smaller frame, bleeding content between
  frames in the gallery.
- decodeFrames/decodeViewFrames/decodeAnimationFrames now always hand
  bitmap.DecodeReferenced a zero buffer instead of the previous
  frame/block's real pixels. The game's actual RSD8 decoder treats a
  "skip" run as literal zero, with no mechanism to carry a reference
  frame's pixels forward (confirmed against Night Dive's published
  source), and hacked's own multi-block Bitmap preview decodes the
  same way. The previous behavior ghosted a previous frame's stale
  content into the current frame's skip-run regions, in both the
  gallery and the AI upscaler's source input.
- index.html now shows a labeled per-frame filmstrip next to each
  animated GIF, and a base-vs-this-build side-by-side comparison for
  every resource a mod overrides.
- Gallery GIFs play back at a third of real speed by default, for
  easier frame-by-frame review.
- modding/mods/*/bitmaps/*.json gains considerPreviousFrames: a
  per-resource list that runs a temporal-consistency pass (median
  across the animation's own frames) on independently-upscaled frames
  to reduce per-frame AI flicker. Applied to 0x7B1 in upscale_medbot.
- Drop the rebecca-test example mod.
2026-10-27 07:54:19 +01:00
ml a8d7ff76df Fix critter sprites cropped in the software renderer
build / build (push) Successful in 2m41s
g3_bitmap_common() (Bitmap.c) used the same bm_w/bm_h for both the
on-screen quad size and its texture-coordinate bounds. The former
should use patch 04's native-size override for a modded/upscaled
bitmap; the latter must always use the bitmap's own real dimensions,
since that's still what's actually being sampled. OpenGL never hit
this (opengl_bitmap() ignores these bounds and always maps the whole
real texture), but the software renderer's h_map() takes them as
literal pixel coordinates to interpolate, so the override capped how
much of a larger bitmap it ever read - cropping anything past the
original sprite's bounds, such as an upscaled critter's limbs.

Split into a separate tex_w/tex_h pair, always the bitmap's real
size, used for every texture-coordinate computation.

README.md: document this, and the previously-undocumented
engine-debug-tools RSD8 decode tracing patch (02).
2026-10-18 07:33:56 +02:00
ml 9d0a0d5798 Add engine-debug-tools and fix critter animation rendering bugs
build / build (push) Successful in 2m52s
- engine-debug-tools/: a debug dump hotkey + dump2png to inspect
  decoded critter frames independent of the renderer (see root
  README.md's "Debugging" section).
- engine-patches/04,05: fix critter sprite scale and an OpenGL
  texture cache collision, found using that tooling.
- mdlp: fix encodeFrames RLE-diffing upscaled frames against a
  previous-frame reference the real RSD8 decoder never implements
  (verified against Night Dive's original Mac source down to the
  68K assembly) - was rendering as scattered static in-game.
- Upscale pipeline: better color quantization (dithering, pure-ramp
  exclusion) and per-resource model selection.
2026-10-18 06:59:47 +02:00
ml ab2b778f44 Add AI-upscaled critter/object art as a new MDL mod type
build / build (push) Successful in 3m36s
Extends the MDL system beyond text overrides: bitmaps/<label>.json
names a model/scale and resource list, and mdlp extracts each
resource's frames, runs them through an ONNX Runtime CPU upscaler
baked into mdlp-image, re-quantizes to the game's palette, and writes
the result back - same build-time pipeline as message overrides.

Adds animgallery, a standalone viewer that renders any data
directory's animated resources as an HTML/GIF gallery with a
checkbox-driven MDL JSON generator, and -only-changed to preview just
what a mod overrides against the base game.

Fixes a real data race in hacked's ChangeNotifier.hashAll() (found via
go build -race causing non-deterministic resource counts), via a new
numbered patch rather than editing the vendored source.

Adds engine-patches/, a general-fixes counterpart to
android/engine-patches/ now staged and applied to the desktop builds
too (previously unpatched): a real GL_UNPACK_ROW_LENGTH stride bug, an
audio error-logging improvement, and two critter-rendering fixes -
mod art was losing to the base game's own re-opened resource files,
and critters rendered at the wrong size/position once that was fixed,
both from how the billboard renderer derives on-screen size and
placement straight from bitmap pixel dimensions.

Updates README.md and AGENTS.md to document engine-patches/.
2026-09-27 20:52:35 +02:00
ml 389abd7de7 Add a first-party MDL mod-build system and reorganize modding/ under src/
modding/mods/ holds message edits as plain JSON (type, index, language,
plus only the changed fields - a long body can live in a sibling .txt to
avoid escaping in JSON), so mod authoring is reviewable in git instead of
living only in hacked's GUI. mdlp (modding/src/mdlp/, its own Docker image
mdlp-image/) turns them into the same mod folder/zip hacked's own Save
produces, reusing hacked's ss1/edit, ss1/world and ss1/content/text
packages via a go.mod replace - no GUI/GLFW/GTK needed. `make mods`
builds every mod into dist/mods/<name>/, scoped per-mod so it never
touches hacked's own build output; `make` (no target) now builds both.

Fixes a bug this surfaced: hacked's FileLocation.AbsolutePathFrom
silently writes into the current directory instead of the given output
folder when passed a relative path (the GUI never hits this since its
folder picker is always absolute) - mdlp now resolves its own paths
up front.

res/run-mods.sh/.bat are new companion launchers to run.sh/.bat: they
pass every dist/mods/*/mod folder to the engine as a separate absolute
argument, since ProcessModDirectory() only scans one directory level
deep. Wired into `make dist`/`dist-win` only, not the release archives.

modding/hacked/, its patches, sqweek-dialog/, its patches, and the new
mdlp/ move under modding/src/, separating source from the surrounding
Docker/script scaffolding; all path references updated to match.

NOTICE.txt gains a section for hacked's distributed build, listing hacked
(New BSD), sqweek/dialog (ISC - corrected from a wrong "New BSD" note),
and hacked's other direct Go dependencies.
2026-09-26 15:41:56 +02:00
ml c0952898ae Moving some directories 2026-09-26 15:20:07 +02:00
ml e410d82680 Fix hacked GTK3/GLFW crash by vendoring a patched sqweek/dialog
The built hacked binary segfaults on Linux the moment a native GTK3
dialog (file/folder browse) opens while a GLFW/OpenGL context is live:
sqweek/dialog's Linux backend never calls XInitThreads() before
gtk_init(), and GLFW's own X11 backend installs a process-wide Xlib
error handler that collides with GTK's.

Vendor a pinned copy of github.com/sqweek/dialog (New BSD) into
modding/sqweek-dialog/ so it can be patched like every other vendored
source here: sqweek-dialog-patches/01-xinitthreads.patch adds the
missing XInitThreads() call, and hacked-patches/01-use-patched-dialog.patch
adds a go.mod replace directive pointing hacked's own sqweek/dialog
dependency at that local copy. build-hacked.sh now stages both hacked/
and sqweek-dialog/ into a scratch modding/build/ directory and applies
their patch series before compiling, rather than building hacked/ in
place; .gitignore picks up that new scratch directory.

Also expand modding/README.md: number its existing sections and add a
worked "Example: Editing a message" walkthrough covering Static World
Data, the Messages window's clipboard-only text editing, saving the
Mod Location, and the absolute-path requirement for run.sh's mod-folder
argument.
2026-09-26 10:37:01 +02:00
ml c91080aba7 Adding modding folder with a hacked build. 2026-09-26 08:53:08 +02:00
ml cdb960dc71 Adding publishing assets for Github and Sidequest 2026-09-26 06:45:46 +02:00
ml 790b4ffd21 Add screenshots, a quick start and a trademark notice to the README
build / build (push) Successful in 3m8s
2026-09-25 20:25:17 +02:00
ml 135e474a14 Add v0.5.3 screenshots and a video; name captures by version.
build / build (push) Successful in 2m52s
2026-09-21 20:14:51 +02:00
ml 295f4869c1 Sign release APKs with a fixed key from CI secrets
build / build (push) Successful in 2m48s
2026-09-20 17:47:43 +02:00
ml f0a83df667 Replace the logo and icon with QuestShock artwork; add a GitHub release workflow
build / build (push) Successful in 2m14s
2026-09-20 17:23:55 +02:00
ml 9586c58a67 Add a QuestShock startup splash; rename the app and data folder
build / build (push) Successful in 2m35s
2026-09-20 16:49:28 +02:00
ml 5d7564c3b5 Update the docs and add AGENTS.md
build / build (push) Successful in 2m18s
2026-09-20 15:25:33 +02:00
ml 70c43240bc Add a Cheats menu tab with engine cheat buttons and unlimited-resource switches
build / build (push) Successful in 3m42s
The new Cheats tab exposes the engine's own cheat handlers as buttons:
give all items and weapons, toggle physics, next level and previous
level. Physics shows its state through the button highlight. The buttons
need a running game and show a message otherwise.

It also has four independent session-only switches for unlimited health,
energy, ammo and items (grenades and drugs). While a switch is on, its
resource is topped up every frame by qs_cheat_refill() from the new
engine patch 26. The switches are not saved to the config.
2026-09-20 14:59:33 +02:00
ml d8324d409e Add free view and a separate cyberspace action set
build / build (push) Successful in 3m24s
- Free view: hold a mapped input (default: left grip) and that thumbstick
  drives the engine's mouse look, with the stick's normal bindings suspended
  while held. Also available for the right stick, unbound by default.
- Cyberspace actions: eleven thrust/climb/dive/bank/roll actions with their
  own bindings, active only in cyberspace. The normal movement actions are
  inactive there, and capture exclusivity only compares actions that can be
  active together, so one input can be bound in both sets.
- Cyberspace pitch and bank scale with thumbstick deflection instead of
  running at full strength.
- Remove the toggle_freelook action; bind the automap view to the right
  stick click by default.
- Engine patches: 24 exposes whether the player is in cyberspace, 25 lets
  the controller code scale cyberspace pitch and bank.
2026-09-20 07:24:44 +02:00
ml c24b7f15cb Add remappable controller controls with a Controller Options tab
build / build (push) Successful in 2m14s
Add a player_controls config group that maps game actions to controller
inputs, and a Controller Options menu tab to rebind them in-headset.

- 45 actions: movement, click/double-click, jump, fire, and the engine's own
  keyboard hotkeys (posture, save/load, weapon/MFD select, etc.), sent to the
  engine as synthetic key events. Cheat and dead hotkeys are not exposed.
- Press-to-capture rebinding with per-input exclusivity, Reset to Defaults
  (built-in defaults, then questshock.config.default.json), and new defaults:
  left stick moves/strafes, right stick turns.
- Controller profiles for Touch, Vive and generic OpenXR
  (res/controller_profiles/), matched to the active interaction profile at
  runtime, with a fatal dialog if a profile's bindings are rejected at
  startup.
- The menu's thumbstick locomotion is disabled while the Controller Options
  tab is open or a capture is active. A scrollable list with a tap-to-jump
  scroll bar handles the long action list.
- Opening the menu pauses a running game and resumes it on close.
- Reset Position now keeps the player's facing instead of turning the screen
  to the current gaze.
- Play the intro video in the game's language (engine patch 23), falling back
  to English.
- Watchdog and shared-key handling for stuck movement keys, plus key-edge
  logging.
2026-09-20 06:08:50 +02:00
ml e1e6a006d4 Add configurable game/initial screen width and distance, with a bundled first-run config
build / build (push) Successful in 2m41s
- Video Options tab offers width and distance presets independently for
  the Initial Screen (splash/intro/menu) and Game Screen (gameplay)
  profiles, with their preset step/offset configurable via
  questshock.config.json.
- Ships res/questshock.config.default.json as the initial config on
  first run (or whenever questshock.config.json doesn't exist yet),
  refreshed from the APK on every launch without touching a user's own
  saved config.
2026-09-06 09:08:54 +02:00
ml 5fe4cdf7f8 Bump AGP to 8.5.2 and Gradle to 8.7 for 16 KB page-size support
build / build (push) Successful in 2m21s
Every .so this project builds was already ELF-segment-aligned for 16 KB
pages (build-image/Dockerfile's ANDROID_16KB_LDFLAGS, app/build.gradle's
own CMAKE_SHARED_LINKER_FLAGS), but AGP 8.1.1 doesn't align native
libraries' zip entries within the APK itself - that only became automatic
in AGP 8.5.1, which is what Android Studio's "not compatible with 16 KB
devices" warning was actually flagging. No build-image changes needed:
Gradle/AGP are resolved over the network at build time, not baked into
the image.
2026-09-05 16:18:29 +02:00
ml 369555efc7 Making the menu slightly larger 2026-09-05 15:53:15 +02:00
ml 360a1f7e3d Add VR locomotion: move/turn with thumbsticks while the menu is open
build / build (push) Successful in 2m25s
- Left thumbstick snap-turns, right thumbstick smooth-moves, letting the
  player line themselves up with the fixed game quad ("virtual screen")
  without needing physical room-scale space to do it in. Implemented via
  a second OpenXR reference space (a "play space") derived from the real
  LOCAL space by the inverse of the player's transform, so every existing
  quad/hit-testing/rendering call site just needed g_local_space swapped
  for it - no manual coordinate-transform math required elsewhere
- Generalized the ray/quad hit-testing in xr_input.c to a proper
  oriented-plane intersection (needed once quads can rotate); verified it
  degenerates to the old axis-aligned formula when unrotated, so the
  keyboard and non-recentered menu are unaffected
- The menu launcher now re-centers in front of the player, correctly
  oriented to face them, each time it's freshly reopened
- "Reset Position" (Main Menu tab) recenters using the player's current
  real head pose - not a plain reset-to-origin, which only undid
  thumbstick movement and ignored physical drift since the session
  started. The same recenter runs once automatically at startup too, as a
  fallback for when there's no complete saved position to use instead
- Position/orientation persists in questshock.config.json's new
  player_position group (independent of savegames), including new
  numeric-value support in the config file's hand-rolled JSON parser
  (previously string-enums only)
2026-09-05 15:47:54 +02:00
ml c0619a55ab Add a language option to the VR menu, and fix a crash switching to it
build / build (push) Successful in 2m22s
- New "Language" row on the Main Menu tab (English/French/German),
  backed by a new "general" group in questshock.config.json
- Fixes a device-reproduced crash: switching language used to call
  language_dealfunc(), whose render_run()/opanel_redraw() repaint
  assumes the game's own in-game options panel is on screen - crashes
  (SIGSEGV) when triggered from our independent VR overlay instead.
  Now calls language_change() directly, which is all that's actually
  needed to reload resources and refresh the inventory/MFD/side-icon UI
- Fixes a second crash: this data set ships frnstrng.res/gerstrng.res
  but not mfdfrn.res/mfdger.res, and language_change() closes the
  current resource files before discovering the new ones don't exist -
  leaving no valid resource file open and crashing on the next lookup.
  Now checks both files exist before ever switching, refusing the
  change (with an in-headset popup) instead of corrupting resource state
- extract_assets.sh can now also pull mfdfrn.res/mfdger.res out of a
  System Shock: Classic installer (which includes them, unlike Enhanced
  Edition) if one is present in res/assets/ - entirely optional
- Documented all of the above in README.md/CONFIG.md
2026-09-05 08:09:12 +02:00
ml 09280dd410 Overhaul the VR menu UI: header, tabs, live video options, hover/click feedback, i18n, and dragging
build / build (push) Successful in 2m25s
- Header with title, git-tag-derived version, and a live FPS/frame-time/
  CPU/GPU-utilization/free-memory stats ribbon below the tab bar (FPS via
  a new per-frame tracker in xr_session.c; CPU/GPU via the OpenXR
  XR_META_performance_metrics extension, gracefully unavailable on
  runtimes that don't support it)
- Bottom tab bar with a live-editable Video Options tab (renderer/
  resolution), applying changes immediately via qs_config.c
- Hover and press feedback on every menu/keyboard button via a
  StateListDrawable (Android's default ripple never animates on this
  permanently unattached view tree), with a lighter default button color
  for visibility
- i18n for all menu/keyboard chrome text, following the game's own
  language setting (Strings.java)
- The main menu is now draggable via its header, matching the keyboard;
  xr_input.c's drag handling is generalized to per-overlay state instead
  of being keyboard-only
- The keyboard's title bar now matches the menu header's style
2026-09-05 07:02:30 +02:00
ml c4fd4eb3dd Adding support for a configuration file 2026-09-05 05:30:00 +02:00
909 changed files with 64500 additions and 436 deletions
+18
View File
@@ -69,7 +69,25 @@ jobs:
dist/shockolate-*-linux-*.tar.gz dist/shockolate-*-linux-*.tar.gz
dist/shockolate-*-windows-*.zip dist/shockolate-*-windows-*.zip
# The release key is stored base64-encoded in ANDROID_KEYSTORE_BASE64
# (see README section 4.3). Without it the APK is a debug build.
- name: Prepare release signing
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: |
set -euo pipefail
if [ -z "$KEYSTORE_BASE64" ]; then
echo "No signing key: the APK will be a debug build"
exit 0
fi
echo "$KEYSTORE_BASE64" | base64 -d > "${RUNNER_TEMP:-/tmp}/questshock-release.jks"
echo "QUESTSHOCK_KEYSTORE=${RUNNER_TEMP:-/tmp}/questshock-release.jks" >> "$GITHUB_ENV"
- name: Build Quest APK - name: Build Quest APK
env:
QUESTSHOCK_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
QUESTSHOCK_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
QUESTSHOCK_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
# QUESTSHOCK_BUILD_IMAGE is already set (see Preflight above), so # QUESTSHOCK_BUILD_IMAGE is already set (see Preflight above), so
# this runs build-apk.sh directly instead of trying to docker-run # this runs build-apk.sh directly instead of trying to docker-run
# the image again - same reasoning as `make package` above. # the image again - same reasoning as `make package` above.
+145
View File
@@ -0,0 +1,145 @@
name: release
# GitHub's build. The Gitea build is .gitea/workflows/build.yml.
#
# Pushing a tag vX.Y.Z builds the Linux and Windows packages and the Quest APK
# inside the build-image and attaches them to a GitHub release. A manual run
# builds the same files and keeps them as a workflow artifact without
# releasing them.
#
# The build-image (build-image/Dockerfile) is published to GHCR, tagged with a
# hash of the files that define it, and only built when that tag does not
# exist yet.
on:
push:
tags:
- "v*"
workflow_dispatch:
permissions:
contents: read
jobs:
image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
name: ${{ steps.name.outputs.name }}
steps:
- uses: actions/checkout@v4
- name: Name the image after the files that define it
id: name
run: |
set -euo pipefail
hash="$(cat build-image/Dockerfile build-image/docker-entrypoint.sh build-image/build-engine.sh | sha256sum | cut -c1-16)"
echo "name=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/questshock-builder:${hash}" >> "$GITHUB_OUTPUT"
- name: Build and push the image if it does not exist yet
env:
IMAGE: ${{ steps.name.outputs.name }}
REGISTRY_USER: ${{ github.actor }}
REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
echo "$REGISTRY_PASSWORD" | docker login ghcr.io -u "$REGISTRY_USER" --password-stdin
if docker manifest inspect "$IMAGE" >/dev/null 2>&1; then
echo "$IMAGE already exists"
exit 0
fi
# The image is large and the runner's disk is small.
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
# The label links the package to this repository, so the build job
# can pull it with the workflow's token.
docker build -f build-image/Dockerfile \
--label "org.opencontainers.image.source=https://github.com/${GITHUB_REPOSITORY}" \
-t "$IMAGE" .
docker push "$IMAGE"
build:
needs: image
runs-on: ubuntu-latest
permissions:
contents: read
packages: read
defaults:
run:
shell: bash
# The job runs inside the build-image, so `make` compiles directly instead
# of starting the image itself (QUESTSHOCK_BUILD_IMAGE is set in it).
container:
image: ${{ needs.image.outputs.name }}
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v4
with:
# The version comes from `git describe --tags`.
fetch-depth: 0
- name: Build the Linux and Windows packages
run: make package
# The release key is stored base64-encoded in ANDROID_KEYSTORE_BASE64; see
# README section 4.3. A tag build must be signed with it.
- name: Prepare release signing
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: |
set -euo pipefail
if [ -z "$KEYSTORE_BASE64" ]; then
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
echo "::error::ANDROID_KEYSTORE_BASE64 is not set: a release APK must be signed"
exit 1
fi
echo "No signing key: the APK will be a debug build"
exit 0
fi
echo "$KEYSTORE_BASE64" | base64 -d > "${RUNNER_TEMP:-/tmp}/questshock-release.jks"
echo "QUESTSHOCK_KEYSTORE=${RUNNER_TEMP:-/tmp}/questshock-release.jks" >> "$GITHUB_ENV"
# Needs network access for Gradle's own dependency resolution.
- name: Build the Quest APK
env:
QUESTSHOCK_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
QUESTSHOCK_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
QUESTSHOCK_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: make apk
- uses: actions/upload-artifact@v4
with:
name: questshock
path: |
dist/shockolate-*-linux-*.tar.gz
dist/shockolate-*-windows-*.zip
dist/questshock-*-android-*.apk
release:
if: startsWith(github.ref, 'refs/tags/')
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
steps:
- uses: actions/download-artifact@v4
with:
name: questshock
path: dist
- name: Create the release, or update its files if it exists
run: |
set -euo pipefail
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
gh release upload "$GITHUB_REF_NAME" dist/* --clobber
else
gh release create "$GITHUB_REF_NAME" dist/* \
--title "$GITHUB_REF_NAME" \
--generate-notes \
--notes "Sideload the APK onto a Meta Quest (see README section 5.1). The game data is not included: you need your own copy of System Shock: Enhanced Edition."
fi
+14
View File
@@ -3,6 +3,7 @@
# 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/*.zip
/res/assets/ss_ee/ /res/assets/ss_ee/
@@ -37,3 +38,16 @@
# 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/
# engine-debug-tools/dump2png's own scratch, patched source copy (see
# engine-debug-tools/build-dump2png.sh)
/engine-debug-tools/build/
+408
View File
@@ -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`.
+458
View File
@@ -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.
+21 -2
View File
@@ -21,7 +21,7 @@ ASSETS_DIR := res/assets/ss_ee
BUILD_DIR := build BUILD_DIR := build
ARCH := $(shell uname -m) ARCH := $(shell uname -m)
.PHONY: all dist dist-linux dist-win build-image engine engine-win assets \ .PHONY: all dist dist-linux dist-win dist_debug build-image engine engine-win assets \
package package-linux package-win apk android-studio clean package package-linux package-win apk android-studio clean
all: dist all: dist
@@ -73,7 +73,7 @@ dist: dist-linux dist-win
dist-linux: engine assets 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/"
@@ -83,8 +83,26 @@ dist-linux: 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 =="
# Same as dist-linux, but also applies engine-debug-tools/engine-patches/
# (debug-only instrumentation - currently a dump hotkey, see
# engine-debug-tools/ and README.md's "Debugging" section), builds
# engine-debug-tools/dump2png into $(DIST_DIR) alongside it, and copies in
# convert-dumps.sh (res/convert-dumps.sh - a dump2png wrapper pointed at
# $(DIST_DIR)'s own res/data and dumps/, same convention as run-mods.sh
# wrapping run.sh). Overwrites whatever dist-linux last produced in
# $(DIST_DIR) - there's one dist/, and which of these two targets built it
# last is the only difference.
dist_debug: export ENGINE_DEBUG_TOOLS := 1
dist_debug: dist-linux
./engine-debug-tools/build-dump2png.sh "$(DIST_DIR)/dump2png"
cp res/convert-dumps.sh "$(DIST_DIR)/convert-dumps.sh"
chmod +x "$(DIST_DIR)/convert-dumps.sh"
@echo "== dist_debug: debug dump hotkey + $(DIST_DIR)/dump2png + $(DIST_DIR)/convert-dumps.sh are ready =="
# Windows counterpart of dist-linux. Unlike dist/, the DLLs sit flat # Windows counterpart of dist-linux. Unlike dist/, the DLLs sit flat
# alongside systemshock.exe instead of in a lib/ subdirectory - Windows' # alongside systemshock.exe instead of in a lib/ subdirectory - Windows'
# default DLL search order already checks the executable's own directory # default DLL search order already checks the executable's own directory
@@ -100,6 +118,7 @@ dist-win: engine-win assets
cp -a "$(ASSETS_DIR)/data/." "$(DIST_WIN_DIR)/res/data/" cp -a "$(ASSETS_DIR)/data/." "$(DIST_WIN_DIR)/res/data/"
cp -a "$(ASSETS_DIR)/sound/." "$(DIST_WIN_DIR)/res/sound/" cp -a "$(ASSETS_DIR)/sound/." "$(DIST_WIN_DIR)/res/sound/"
cp res/run.bat "$(DIST_WIN_DIR)/run.bat" 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 ==" @echo "== Done - run $(DIST_WIN_DIR)/run.bat (or systemshock.exe) to play =="
# Builds both platforms' redistributable archives. `package-linux`/ # Builds both platforms' redistributable archives. `package-linux`/
+17
View File
@@ -8,6 +8,9 @@ and is not relicensed by this project's MIT license.
Game assets are not included - see res/GET_ASSETS.txt. Game assets are not included - see res/GET_ASSETS.txt.
System Shock is a trademark of its respective owners. This is an unofficial
fan project and is not affiliated with, endorsed by or supported by them.
The Android build additionally bundles: The Android build additionally bundles:
- GL4ES (https://github.com/ptitSeb/gl4es), vendored and patched (see - GL4ES (https://github.com/ptitSeb/gl4es), vendored and patched (see
@@ -32,3 +35,17 @@ cross-build") additionally bundles:
statically into systemshock.exe instead of shipped as a DLL, under the statically into systemshock.exe instead of shipped as a DLL, under the
GCC Runtime Library Exception - this doesn't subject the rest of the GCC Runtime Library Exception - this doesn't subject the rest of the
binary to the GPL.) 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).
+443 -26
View File
@@ -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,11 @@ a cross-platform port of the original game.
proprietary game assets (ships `res/GET_ASSETS.txt` in their place). proprietary game assets (ships `res/GET_ASSETS.txt` in their place).
See "4.1. Packaging a distributable build" below. See "4.1. Packaging a distributable build" below.
- `apk` - builds `dist/questshock-<version>-android-arm64.apk` for - `apk` - builds `dist/questshock-<version>-android-arm64.apk` for
Android-based OpenXR headsets. Unlike every other target, this needs Android-based OpenXR headsets (a debug build, `...-arm64-debug.apk`,
network access at build time (Gradle/AGP's own dependency when there is no signing key - see "4.3. Releases and signing" below).
resolution). See "5. Android / Quest build" below. Unlike every other target, this needs network access at build time
(Gradle/AGP's own dependency resolution). See "5. Android / Quest
build" below.
- `android-studio` - stages `engine/`/`android/gl4es-src/` (scratch, - `android-studio` - stages `engine/`/`android/gl4es-src/` (scratch,
patched copies) and the Android prebuilts with host-resolvable paths, patched copies) and the Android prebuilts with host-resolvable paths,
so Android Studio can compile and deploy `android/` natively instead so Android Studio can compile and deploy `android/` natively instead
@@ -90,6 +162,25 @@ 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 the desktop (your dev machine, or Builds and runs Questshock natively on the desktop (your dev machine, or
@@ -157,10 +248,11 @@ of `build-image/Dockerfile`) - no Windows machine, Wine, or VM involved
in building it. `engine/CMakeLists.txt` already has working `WIN32`/ in building it. `engine/CMakeLists.txt` already has working `WIN32`/
`MINGW` branches from Shockolate's own upstream Windows build (built `MINGW` branches from Shockolate's own upstream Windows build (built
natively via Git Bash/MinGW on a real Windows machine - see natively via Git Bash/MinGW on a real Windows machine - see
`engine/build_win64.sh`/`engine/appveyor.yml`), so unlike the Quest `engine/build_win64.sh`/`engine/appveyor.yml`) - this cross-compiles those
build's `android/engine-patches/`, no source patching is needed here - same branches offline and reproducibly, from Linux, via Docker. It applies
this cross-compiles those same branches offline and reproducibly, from the same `engine-patches/` series the Linux build does (section
Linux, via Docker. 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 `dist-win/`/the packaged `.zip` ship `systemshock.exe` with its DLLs
(SDL2, SDL2_mixer, GLEW, fluidsynth-lite, plus the MinGW pthread runtime) (SDL2, SDL2_mixer, GLEW, fluidsynth-lite, plus the MinGW pthread runtime)
@@ -176,6 +268,251 @@ 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 per animation frame (`objsim.c`'s
`cache_critter_native_size()`/`get_critter_native_size()`, keyed by
resource ID and frame index - an animation's bounding box commonly
changes size between poses, so a single per-view size doesn't fit every
frame of it) 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. Since
this same size also sets `g3_bitmap_common()`'s texture coordinate
range, not just its world-space extent, caching only the first frame's
size and reusing it for every frame of an animation also visibly
corrupted the UV mapping on any frame whose real size differed from the
first - caught once a mod's per-frame sizes started genuinely varying
(see `modding/README.md` section 5).
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.
`g3_bitmap_common()`'s world-space size and its texture-coordinate range
are computed from the same `bm_w`/`bm_h` locals, but only the former
should ever use this patch's override - the pixels actually being
sampled are still the bitmap's own real (possibly upscaled) buffer,
regardless of what on-screen size it's drawn at. Overriding both left the
texture-coordinate range capped at the smaller, un-upscaled size while
the real bitmap underneath it was larger, so the renderer only ever
sampled that smaller top-left region of it - cropping off anything past
it, such as an upscaled critter's limbs extending beyond its original
sprite's bounding box. OpenGL never showed this: `opengl_bitmap()`
ignores `g3_bitmap_common()`'s computed `u`/`v` entirely and always maps
the *whole* bound texture over the quad via fixed `0.0`-`1.0` corners.
The software renderer's own scanline texture mapper (`h_map()`,
`gentm.c`) has no such shortcut - it takes `u`/`v` as literal pixel
coordinates into the bitmap and interpolates between exactly the values
it's given, so a capped range there directly caps how much of the real
bitmap it ever reads. Fixed by splitting `g3_bitmap_common()`'s single
`bm_w`/`bm_h` into the override-aware pair (world-space size only) and a
`tex_w`/`tex_h` pair that always holds the bitmap's own real dimensions,
used for every texture-coordinate computation (including the later
blend-buffer-size check, which sizes a buffer against the same real
pixel data these coordinates sample from).
- **`05-desktop-fix-texture-cache-collision.patch`** - `OpenGL.cc`'s
per-bitmap GL texture cache (`texturesByBitsPtr`) was keyed by
`bm->bits | (w << 32) | (h << 48)`, a plain bitwise OR rather than a
genuinely disjoint encoding - `bm->bits` is a full pointer, and a 64-bit
Linux heap/mmap address routinely has non-zero bits in the 32-47 range
the width field occupies, so a different `(bits, w, h)` triple can
collide with an already-cached entry's key. `bm->bits` itself is a
recycled slot in the resource system's own bounded LRU cache, not a
stable per-resource address, making a collision a real risk rather than
a theoretical one. On a collision, `set_texture()` (same file) treats it
as a genuine cache hit and reuses that entry's SDL surfaces - allocated
for whatever `(w, h)` was cached before - for an `SDL_memmove` and
`glTexImage2D` sized to the *current* bitmap's byte count, corrupting
the result with a buffer overflow or leftover stale pixel data from a
previous, differently-sized bitmap. Fixed by keying the cache on a
`std::tuple<void *, int, int>` instead, which `std::map` compares
field-by-field with no collision risk - a hit now always means an exact
`(bits, w, h)` match, so the cached surfaces are guaranteed to already
be sized correctly for the current bitmap.
A second, independent bug in the same cache: on a hit, `set_texture()`
"refreshed" the cached `SDL_Surface` with
`SDL_memmove(t->bitmap->pixels, bm->bits, ...)` - but `t->bitmap` was
created by `convert_texture()` via `SDL_CreateRGBSurfaceFrom(bm->bits,
...)`, which wraps the given pointer rather than copying it, so
`t->bitmap->pixels` *is* `bm->bits` from whenever this entry was first
cached. Since a hit means the current call's `bm->bits` is that same
pointer by definition (it's part of the key), that memmove was always a
no-op self-copy - it never refreshed anything. `bm->bits` is a recycled
resource-cache slot reused for different content across draws while its
address stays the same, so a hit here was never a guarantee the pixels
underneath were still current, only that this entry's GL texture and
converted-surface buffers are the right size to reuse. Fixed by
re-pointing `t->bitmap->pixels`/`pitch` at the current frame's real
source (`bm->bits`, or the RSD8-decoded buffer) before blitting, instead
of copying into what was already the same address. Most noticeable with
content whose per-frame bitmap dimensions vary a lot, such as critter
animations after an AI upscale mod re-exports each frame at its own
independently upscaled size (see `modding/README.md` section 5) -
vanilla assets mostly dodge the first bug because many critter
animations reuse the same bounding-box size across most of their
frames, but the second bug affects any critter whose rendering revisits
the same resource-cache slot across different draws, regardless of mod
content.
### 4.5. Debugging
`engine-debug-tools/` holds engine instrumentation that's useful for
diagnosing rendering bugs but has no place in a normal build - kept
separate from `engine-patches/` (section 4.4) so it's never silently
included in `make dist`/`make package`.
- `engine-debug-tools/engine-patches/` - patches in the same format and
numbered the same way as `engine-patches/` (section 4.4's own process
applies: scratch copy, `patch -p1`, regenerate via `diff -u` rather than
hand-editing hunks), applied on top of the regular `engine-patches/`
series, never in place of it. Two patches so far:
- **`01`** - a debug dump hotkey: pressing **K** in-game dumps every
frame of the currently/most-recently-drawn critter's animation to
`dumps/` (created next to the running binary) as raw palette-index
pixel data - 2-byte LE width, 2-byte LE height, then width\*height
index bytes, one file per frame, named `id<resource-id>_frame<N>.raw`.
Deliberately independent of the renderer's own texture/GL state, so a
frame already corrupted in the dump is a decode-time bug, while a
frame that's clean in the dump but wrong on screen is a rendering-time
one.
- **`02`** - RSD8 decode tracing: `gr_rsd8_convert()`/`gr_rsd8_unpack()`
(`engine/src/Libraries/2D/Source/RSD/`) print a `[debug rsd8]` summary
per frame (decode path, source/destination dimensions, bytes written
versus the bitmap's own area) and a `[debug rsd8 op]` line per RLE
opcode consumed, gated by a flag `debug_dump_critter_animation()` (01,
above) sets only around its own decode calls - tracing covers exactly
the frames the K hotkey dumps, not every RSD8 decode in the game. Each
frame is also decoded a second time back to back and logged as
`identical`/`DIFFERS` against the first, to tell a decode result that's
deterministic given the compressed source (a bad source stream, or a
decoder bug it triggers) apart from one that depends on something else,
such as a resource-cache slot being reused mid-decode.
- `engine-debug-tools/dump2png/` - converts those `.raw` dumps into
viewable PNGs using the real game palette (the dump format has no
palette of its own, to keep the in-game dump path from depending on the
renderer's own palette/SDL state). Builds the same way
`modding/gen_animgallery.sh` builds `animgallery` - against
`modding/src/hacked` inside `modding/mdlp-image`'s Go toolchain, since
neither has a cgo dependency of its own - via
`engine-debug-tools/build-dump2png.sh <output-path>`.
`make dist_debug` builds everything above into `dist/` alongside the
normal `make dist` output: the engine with `engine-debug-tools/
engine-patches/` applied in addition to the regular `engine-patches/`
series, plus `dist/dump2png` and `dist/convert-dumps.sh` (a `dump2png`
wrapper, same convention as `run-mods.sh` wrapping `run.sh`, pointed at
`dist/`'s own `res/data` and `dumps/` so no arguments are needed). There's
one `dist/` - whichever of `dist`/`dist_debug` ran last determines whether
the debug hotkey, `dump2png` and `convert-dumps.sh` are present; running
the other target again overwrites it back.
```sh
make dist_debug
./dist/run.sh # or run-mods.sh - play until the bug shows, press K, quit
./dist/convert-dumps.sh # writes dist/dumps-png/*.png from dist/dumps/*.raw
```
## 5. Android / Quest build ## 5. Android / Quest build
**Prerequisites:** **Prerequisites:**
@@ -190,7 +527,8 @@ resulting tarball to dl.ladkau.de.
apk` needs its own separate toolchain - see "5.2. Building natively in apk` needs its own separate toolchain - see "5.2. Building natively in
Android Studio" below. Android Studio" below.
`make apk` builds `dist/questshock-<version>-android-arm64.apk` - an `make apk` builds `dist/questshock-<version>-android-arm64.apk` (or
`...-arm64-debug.apk`, see "4.3. Releases and signing" above) - an
immersive OpenXR app (see `android/app/src/main/cpp/xr_session.c`) that immersive OpenXR app (see `android/app/src/main/cpp/xr_session.c`) that
can be sideloaded onto any Android-based VR headset with OpenXR support can be sideloaded onto any Android-based VR headset with OpenXR support
(Meta Quest, Pico, etc. - see "1. Design principles" above), not just one (Meta Quest, Pico, etc. - see "1. Design principles" above), not just one
@@ -203,18 +541,17 @@ driven by a laser-pointer-style aim ray from each hand
interact with it, same as the game's own Bluetooth mouse/keyboard input interact with it, same as the game's own Bluetooth mouse/keyboard input
otherwise works unchanged. otherwise works unchanged.
The menu launcher (`MenuOverlay.java` - just a "Keyboard" button so far) The menu (`MenuOverlay.java`, described in "5.1.1. Menu, controls and
and the keyboard (`KeyboardOverlay.java`) are two fully independent quads, cheats" below) and the keyboard (`KeyboardOverlay.java`) are two fully
each its own `XrOverlay` instance (`android/app/src/main/cpp/xr_overlay.c` independent quads, each its own `XrOverlay` instance
- a generic "off-screen Android View rendered into an OpenXR quad, (`android/app/src/main/cpp/xr_overlay.c` - a generic "off-screen Android View
hit-tested via a laser pointer" module shared by both, and by whatever rendered into an OpenXR quad, hit-tested via a laser pointer" module shared by
similar panel comes next) with its own swapchain, visibility, and both) with its own swapchain, visibility, and position, so they can be
position, so they can be shown/hidden/moved independently - a design shown/hidden/moved independently - the keyboard can stay open while also
specifically meant to let the keyboard stay open while also picking picking something from the menu. The controller's menu button only ever
something from the (still-to-be-built-out) menu. The controller's menu opens/closes the menu; the menu's "Keyboard" button only opens the keyboard
button only ever opens/closes the launcher; clicking its "Keyboard" button (never touching the menu's own visibility) - the keyboard's own title bar
only opens the keyboard (never touching the launcher's own visibility) - holds the only way to close it again.
the keyboard's own title bar holds the only way to close it again.
The keyboard is a hand-built, full US-layout key grid (there's no system The keyboard is a hand-built, full US-layout key grid (there's no system
IME to borrow once immersive) that forwards each key press straight to the IME to borrow once immersive) that forwards each key press straight to the
@@ -260,16 +597,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
@@ -330,7 +718,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.
@@ -508,6 +896,31 @@ each one does:
goes through the OpenXR swapchain's own FBOs, set up separately in goes through the OpenXR swapchain's own FBOs, set up separately in
`xr_swapchain.c`) and is also functionally dead there regardless - `xr_swapchain.c`) and is also functionally dead there regardless -
skipped entirely, Android-only. 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/`:
@@ -855,6 +1268,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
+60 -5
View File
@@ -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_overlay.c;${projectDir}/src/main/cpp/xr_swapchain.c;${projectDir}/src/main/cpp/xr_mouse.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,11 +212,37 @@ 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
+9 -7
View File
@@ -38,6 +38,11 @@
OpenXR session to actually start. --> OpenXR session to actually start. -->
<meta-data android:name="com.oculus.vr.focusaware" android:value="true" /> <meta-data android:name="com.oculus.vr.focusaware" android:value="true" />
<!-- Meta-specific: the headsets this app is built for. Other OpenXR
runtimes ignore it. -->
<meta-data android:name="com.oculus.supportedDevices"
android:value="quest2|questpro|quest3|quest3s" />
<activity android:name="de.ladkau.questshock.QuestShockActivity" <activity android:name="de.ladkau.questshock.QuestShockActivity"
android:label="@string/app_name" android:label="@string/app_name"
android:alwaysRetainTaskState="true" android:alwaysRetainTaskState="true"
@@ -51,13 +56,10 @@
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
<!-- The vendor-neutral, Khronos-defined way to declare an <!-- The vendor-neutral, Khronos-defined way to declare an
immersive OpenXR entry point (vs. e.g. Meta's own immersive OpenXR entry point, plus Meta's own VR
com.oculus.intent.category.VR) - see this project's category, which Horizon OS looks for to treat the app as
"favor OpenXR" rule in the README. If Horizon OS still a VR app. Other runtimes ignore the Meta one. -->
launches this as a flat panel on-device, the next thing <category android:name="com.oculus.intent.category.VR" />
to try is adding a com.oculus.supportedDevices meta-data
listing the target Quest models - not confirmed
necessary from static analysis alone. -->
<category android:name="org.khronos.openxr.intent.category.IMMERSIVE_HMD" /> <category android:name="org.khronos.openxr.intent.category.IMMERSIVE_HMD" />
</intent-filter> </intent-filter>
<intent-filter> <intent-filter>
File diff suppressed because it is too large Load Diff
+195
View File
@@ -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
+110
View File
@@ -0,0 +1,110 @@
#include "qs_splash.h"
#include <SDL.h>
#include <android/log.h>
#include <stdbool.h>
#include <string.h>
#include "xr_session.h"
#define QS_SPLASH_HOLD_MS 2500
#define QS_SPLASH_FADE_MS 500
#define QS_SPLASH_TOTAL_MS (QS_SPLASH_HOLD_MS + 2 * QS_SPLASH_FADE_MS)
// The XR session only starts once frames are polled, so the display time is
// counted from the first submitted frame; this bounds the wait for it.
#define QS_SPLASH_SESSION_TIMEOUT_MS 5000
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, "QuestShock", __VA_ARGS__)
extern void pump_events(void);
// OpenGL.cc (engine patch 11): draws a software frame into the XR game swapchain.
extern void android_composite_software_frame(SDL_Surface *ui);
static SDL_Surface *s_image = NULL;
void qs_splash_set_image(const uint32_t *argb, int width, int height) {
if (s_image != NULL) {
SDL_FreeSurface(s_image);
s_image = NULL;
}
if (argb == NULL || width <= 0 || height <= 0)
return;
s_image = SDL_CreateRGBSurfaceWithFormat(0, width, height, 32, SDL_PIXELFORMAT_ARGB8888);
if (s_image == NULL)
return;
for (int y = 0; y < height; y++)
memcpy((uint8_t *)s_image->pixels + (size_t)y * s_image->pitch, argb + (size_t)y * width,
(size_t)width * sizeof(uint32_t));
SDL_SetSurfaceBlendMode(s_image, SDL_BLENDMODE_BLEND);
}
// 0-255 brightness at `elapsed` ms after the first frame.
static int qs_splash_level(uint32_t elapsed) {
uint32_t edge = elapsed < QS_SPLASH_TOTAL_MS - elapsed ? elapsed : QS_SPLASH_TOTAL_MS - elapsed;
if (edge >= QS_SPLASH_FADE_MS)
return 255;
return (int)(255 * edge / QS_SPLASH_FADE_MS);
}
void qs_splash_show(void) {
if (s_image == NULL) {
LOGI("Startup splash: no image");
return;
}
int width, height;
xr_get_game_swapchain_size(&width, &height);
if (width <= 0 || height <= 0) {
LOGI("Startup splash: no XR game swapchain");
return;
}
// The image is stretched over the whole swapchain image, so it is
// letterboxed into a canvas of that size to keep its aspect ratio.
SDL_Surface *canvas =
SDL_CreateRGBSurfaceWithFormat(0, width, height, 32, SDL_PIXELFORMAT_RGBA32);
if (canvas == NULL)
return;
const float scale = SDL_min((float)width / s_image->w, (float)height / s_image->h);
SDL_Rect dst;
dst.w = (int)(s_image->w * scale);
dst.h = (int)(s_image->h * scale);
dst.x = (width - dst.w) / 2;
dst.y = (height - dst.h) / 2;
const uint32_t waitStart = SDL_GetTicks();
uint32_t shownStart = 0;
uint32_t elapsed = 0;
int drawnLevel = -1;
bool done = false;
while (!done) {
pump_events();
xr_poll_events();
if (xr_frame_begin()) {
if (shownStart == 0)
shownStart = SDL_GetTicks();
elapsed = SDL_GetTicks() - shownStart;
if (elapsed >= QS_SPLASH_TOTAL_MS) {
elapsed = QS_SPLASH_TOTAL_MS;
done = true;
}
const int level = qs_splash_level(elapsed);
if (level != drawnLevel) {
drawnLevel = level;
SDL_FillRect(canvas, NULL, SDL_MapRGB(canvas->format, 0, 0, 0));
SDL_SetSurfaceColorMod(s_image, level, level, level);
SDL_BlitScaled(s_image, NULL, canvas, &dst);
}
android_composite_software_frame(canvas);
} else {
SDL_Delay(10);
if (shownStart == 0 && SDL_GetTicks() - waitStart > QS_SPLASH_SESSION_TIMEOUT_MS)
done = true;
}
xr_frame_end();
}
LOGI("Startup splash: %s after %u ms", shownStart != 0 ? "shown" : "XR session did not start",
(unsigned)(SDL_GetTicks() - waitStart));
SDL_FreeSurface(canvas);
}
+15
View File
@@ -0,0 +1,15 @@
// Startup splash: the QuestShock title, shown on the game screen before the
// engine's own splash sequence.
#pragma once
#include <stdint.h>
// Stores a copy of the splash image (straight-alpha ARGB, one int per pixel).
// Called from JNI before the engine starts; replaces any earlier image. Without
// an image qs_splash_show() does nothing.
void qs_splash_set_image(const uint32_t *argb, int width, int height);
// Engine thread only (called from engine patch 27). Fades the image in and out
// on the game screen and returns when done, or after a few seconds if the XR
// session never starts.
void qs_splash_show(void);
@@ -10,6 +10,9 @@
#include <SDL.h> #include <SDL.h>
#include "qs_config.h"
#include "qs_splash.h"
#include "xr_input.h"
#include "xr_overlay.h" #include "xr_overlay.h"
#include "xr_session.h" #include "xr_session.h"
@@ -20,6 +23,15 @@ Java_de_ladkau_questshock_QuestShockActivity_nativeChdir(JNIEnv *env, jclass cla
(*env)->ReleaseStringUTFChars(env, path, cpath); (*env)->ReleaseStringUTFChars(env, path, cpath);
} }
JNIEXPORT void JNICALL Java_de_ladkau_questshock_QuestShockActivity_nativeSetSplashImage(
JNIEnv *env, jclass clazz, jintArray pixels, jint width, jint height) {
jint *data = (*env)->GetIntArrayElements(env, pixels, NULL);
if (data == NULL)
return;
qs_splash_set_image((const uint32_t *)data, width, height);
(*env)->ReleaseIntArrayElements(env, pixels, data, JNI_ABORT);
}
// KeyboardOverlay's hand-built on-screen keyboard (see // KeyboardOverlay's hand-built on-screen keyboard (see
// KeyboardOverlay.handleCharKeyDown()/handleCharKeyUp()) has no real // KeyboardOverlay.handleCharKeyDown()/handleCharKeyUp()) has no real
// Android IME session behind it, so printable characters can't reach the // Android IME session behind it, so printable characters can't reach the
@@ -88,3 +100,199 @@ JNIEXPORT void JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeShowKeyboard(JNIEnv *env, jclass clazz) { Java_de_ladkau_questshock_MenuOverlay_nativeShowKeyboard(JNIEnv *env, jclass clazz) {
xr_overlay_set_visible(xr_session_get_keyboard_overlay(), true); 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();
}
File diff suppressed because it is too large Load Diff
+79
View File
@@ -66,6 +66,85 @@ void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw);
// frame. // frame.
bool xr_input_get_beam_layer(int hand, XrCompositionLayerQuad *out_quad); 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
+53 -3
View File
@@ -1,6 +1,7 @@
#include "xr_overlay.h" #include "xr_overlay.h"
#include <dlfcn.h> #include <dlfcn.h>
#include <math.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
@@ -18,11 +19,33 @@
#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__)
// 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 { struct XrOverlay {
XrInstance instance; // for error logging only XrInstance instance; // for error logging only
int width, height; int width, height;
float distance_m, half_width_m, half_height_m; float distance_m, half_width_m, half_height_m;
float center_x_m, center_y_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; bool visible;
XrSwapchainState swapchain; XrSwapchainState swapchain;
@@ -205,6 +228,7 @@ XrOverlay *xr_overlay_create(XrInstance instance, XrSession session, int64_t swa
overlay->width = config->width; overlay->width = config->width;
overlay->height = config->height; overlay->height = config->height;
overlay->distance_m = config->distance_m; overlay->distance_m = config->distance_m;
overlay->default_distance_m = config->distance_m;
overlay->half_width_m = config->width_m * 0.5f; 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->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_x_m = config->default_center_x_m;
@@ -281,9 +305,10 @@ void xr_overlay_set_visible(XrOverlay *overlay, bool visible) {
bool xr_overlay_is_visible(const XrOverlay *overlay) { return overlay != NULL && overlay->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, 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 *distance_m, float *half_width_m, float *half_height_m,
float *yaw_deg) {
if (overlay == NULL) { if (overlay == NULL) {
*center_x_m = *center_y_m = *distance_m = *half_width_m = *half_height_m = 0.0f; *center_x_m = *center_y_m = *distance_m = *half_width_m = *half_height_m = *yaw_deg = 0.0f;
return; return;
} }
*center_x_m = overlay->center_x_m; *center_x_m = overlay->center_x_m;
@@ -291,6 +316,7 @@ void xr_overlay_get_quad_extent(const XrOverlay *overlay, float *center_x_m, flo
*distance_m = overlay->distance_m; *distance_m = overlay->distance_m;
*half_width_m = overlay->half_width_m; *half_width_m = overlay->half_width_m;
*half_height_m = overlay->half_height_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) { void xr_overlay_set_position(XrOverlay *overlay, float center_x_m, float center_y_m) {
@@ -300,6 +326,20 @@ void xr_overlay_set_position(XrOverlay *overlay, float center_x_m, float center_
overlay->center_y_m = center_y_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) { void xr_overlay_touch(XrOverlay *overlay, float u, float v, bool down) {
if (overlay == NULL) if (overlay == NULL)
return; return;
@@ -443,7 +483,17 @@ bool xr_overlay_render_and_build_layer(XrOverlay *overlay, XrCompositionLayerQua
out_quad->subImage.swapchain = overlay->swapchain.swapchain; out_quad->subImage.swapchain = overlay->swapchain.swapchain;
out_quad->subImage.imageRect.extent.width = overlay->swapchain.width; out_quad->subImage.imageRect.extent.width = overlay->swapchain.width;
out_quad->subImage.imageRect.extent.height = overlay->swapchain.height; out_quad->subImage.imageRect.extent.height = overlay->swapchain.height;
out_quad->pose.orientation.w = 1.0f; // 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.x = overlay->center_x_m;
out_quad->pose.position.y = overlay->center_y_m; out_quad->pose.position.y = overlay->center_y_m;
out_quad->pose.position.z = -overlay->distance_m; out_quad->pose.position.z = -overlay->distance_m;
+27 -6
View File
@@ -58,18 +58,39 @@ void xr_overlay_toggle_visible(XrOverlay *overlay);
void xr_overlay_set_visible(XrOverlay *overlay, bool visible); void xr_overlay_set_visible(XrOverlay *overlay, bool visible);
bool xr_overlay_is_visible(const XrOverlay *overlay); bool xr_overlay_is_visible(const XrOverlay *overlay);
// Local-space pose/size of the quad, valid regardless of visibility - // Base-space pose/size of the quad, valid regardless of visibility - shared
// shared with xr_input.c's ray/quad hit-testing. // 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, 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 *distance_m, float *half_width_m, float *half_height_m,
float *yaw_deg);
// Repositions the quad (world-space X/Y offset, same units/space as // 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_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 // xr_input.c while the user drags a panel's title bar. Distance from the
// viewer isn't adjustable this way - only left/right/up/down // viewer and rotation aren't adjustable this way - only left/right/up/down
// repositioning, not push/pull. // 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); 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) // 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. // to the backing Java view as a synthetic touch down/up.
void xr_overlay_touch(XrOverlay *overlay, float u, float v, bool down); void xr_overlay_touch(XrOverlay *overlay, float u, float v, bool down);
+432 -35
View File
@@ -1,5 +1,7 @@
#include "xr_session.h" #include "xr_session.h"
#include <ctype.h>
#include <math.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
@@ -24,14 +26,18 @@
#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
// The menu launcher (just a "Keyboard" button - see MenuOverlay.java) is // The menu launcher (just a "Keyboard" button - see MenuOverlay.java) is
// small and sits above where the keyboard defaults to; the keyboard keeps // small and sits above where the keyboard defaults to; the keyboard keeps
@@ -39,10 +45,22 @@
// xr_overlay.h) so both can be shown/hidden/dragged separately - deliberately // xr_overlay.h) so both can be shown/hidden/dragged separately - deliberately
// positioned apart by default so they don't start out overlapping. // positioned apart by default so they don't start out overlapping.
#define MENU_JAVA_CLASS "de.ladkau.questshock.MenuOverlay" #define MENU_JAVA_CLASS "de.ladkau.questshock.MenuOverlay"
#define MENU_WIDTH 512 // 768x800 (was 512x256, then 768x512, then 768x552) - grown to fit the
#define MENU_HEIGHT 256 // 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 #define MENU_DISTANCE_METERS 1.3f
#define MENU_WIDTH_METERS 0.35f // Physical size - was 0.35m, too small to read comfortably at
// MENU_DISTANCE_METERS given how much text the header/tabs/stats ribbon
// pack in; same pixel resolution (MENU_WIDTH/HEIGHT above) just displayed
// bigger, not a layout change.
#define MENU_WIDTH_METERS 0.5f
#define MENU_DEFAULT_CENTER_X_METERS 0.0f #define MENU_DEFAULT_CENTER_X_METERS 0.0f
#define MENU_DEFAULT_CENTER_Y_METERS 0.15f #define MENU_DEFAULT_CENTER_Y_METERS 0.15f
@@ -58,6 +76,26 @@ 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*
@@ -79,6 +117,40 @@ 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_game_image = false; static bool g_have_acquired_game_image = false;
// XR_META_performance_metrics support (see xr_session_init_performance_metrics()
// below) - only present if the runtime advertised it at xrCreateInstance
// time (checked via xr_instance_extension_supported()), since unlike the
// two always-required KHR extensions above, this one is a device-capability
// extra that a build shouldn't hard-depend on.
static bool g_performance_metrics_supported = false;
static PFN_xrSetPerformanceMetricsStateMETA g_xrSetPerformanceMetricsStateMETA = NULL;
static PFN_xrQueryPerformanceMetricsCounterMETA g_xrQueryPerformanceMetricsCounterMETA = NULL;
// Resolved once, at startup, by string-matching the runtime's own
// human-readable counter path names (see xr_session_init_performance_metrics())
// rather than hardcoding the exact path strings the spec defines - a
// substring match is robust to spec/runtime wording we can't verify without
// 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;
// XR_KHR_composition_layer_cylinder support - same "only if the runtime
// advertised it" reasoning as g_performance_metrics_supported above. If
// unsupported, the curve is forced to 0 (flat) everywhere regardless of
// configuration (see xr_get_game_quad_extent()) so the game screen just
// silently stays flat instead of failing to submit a layer at all.
static bool g_cylinder_layer_supported = false;
// The game screen's currently-configured shape (see qs_config.c's
// qs_screen_profile_notify_loop_mode(), which is what actually calls
// xr_session_set_game_screen_shape() below - this file has no notion of
// "which profile" is active, only "what shape is current"). Defaults
// match the game quad's own original, pre-this-feature hardcoded width
// (1.6m) and lack of curve, so a build that never touches the new config
// options renders identically to before.
static float g_screen_width_m = 1.6f;
static float g_screen_curve_deg = 0.0f;
static float g_screen_distance_m = QUAD_DISTANCE_METERS;
static bool xr_check(XrResult result, const char *what) { static bool xr_check(XrResult result, const char *what) {
if (XR_SUCCEEDED(result)) if (XR_SUCCEEDED(result))
return true; return true;
@@ -99,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
@@ -133,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();
@@ -146,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;
@@ -157,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);
@@ -206,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;
@@ -213,6 +462,14 @@ static bool xr_create_instance_and_session(int game_width, int game_height) {
"xrCreateReferenceSpace")) "xrCreateReferenceSpace"))
return false; return false;
// Picks up whatever qs_config_load_startup() already stored into
// g_player_x/z/yaw_deg via xr_session_set_player_transform() before
// 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
// data conventionally is) and get uploaded/sampled as plain linear // data conventionally is) and get uploaded/sampled as plain linear
@@ -354,10 +611,77 @@ bool xr_frame_begin(void) {
return g_have_acquired_game_image; return g_have_acquired_game_image;
} }
// 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;
// Rebuilt before xr_input_sync_and_draw() below so this same frame's
// hit-testing and layer submission both see whatever the player's
// thumbstick input (or a config-driven "Reset Position"/Load
// Configuration) changed last frame - a no-op check on every frame
// nothing moved.
if (g_player_transform_dirty)
xr_session_rebuild_play_space();
// Syncs actions and hit-tests/dispatches each hand's ray against // Syncs actions and hit-tests/dispatches each hand's ray against
// keyboard/menu/game quad (see xr_input.c) before the game swapchain // keyboard/menu/game quad (see xr_input.c) before the game swapchain
// image is released below - this always runs, even when there's // image is released below - this always runs, even when there's
@@ -366,7 +690,7 @@ void xr_frame_end(void) {
// laser-beam quads are submitted as their own composition layers // laser-beam quads are submitted as their own composition layers
// further down, not drawn into the game swapchain itself. // 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, xr_input_sync_and_draw(g_play_space, g_predicted_display_time,
g_have_acquired_game_image); g_have_acquired_game_image);
if (g_have_acquired_game_image) if (g_have_acquired_game_image)
@@ -375,34 +699,58 @@ void xr_frame_end(void) {
if (!xr_is_session_running()) if (!xr_is_session_running())
return; return;
// Up to 5 layers: the game quad (if a frame was actually rendered), // Up to 5 layers: the game screen (if a frame was actually rendered) -
// the menu launcher and keyboard overlays while visible - each gets // a flat quad, or a curved cylinder if the currently-active screen
// its own acquire/render/release cycle against its own swapchain (see // profile has a nonzero curve (see xr_get_game_quad_extent()'s own
// xr_overlay_render_and_build_layer()) any time before xrEndFrame, // comment; xr_input.c's xr_input_try_game_quad() hit-tests whichever
// unlike the game quad there's no per-frame engine rendering to wrap // shape is actually submitted here, always agreeing since both read
// around here, just each overlay's own blit - and, per hand, a // the same getter) - the menu launcher and keyboard overlays while
// laser-beam quad while that hand's ray is aimed at the keyboard, // visible - each gets its own acquire/render/release cycle against
// menu, or the game quad itself (see xr_input_get_beam_layer()). // 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};
if (gameScreenCurved) {
gameCylinder.space = g_play_space;
gameCylinder.eyeVisibility = XR_EYE_VISIBILITY_BOTH;
gameCylinder.subImage.swapchain = g_game_swapchain.swapchain;
gameCylinder.subImage.imageRect.extent.width = g_game_swapchain.width;
gameCylinder.subImage.imageRect.extent.height = g_game_swapchain.height;
gameCylinder.pose.orientation.w = 1.0f;
gameCylinder.pose.position.z = -gameDistance;
gameCylinder.radius = gameRadius;
gameCylinder.centralAngle = gameCentralAngle;
gameCylinder.aspectRatio = (float)g_game_swapchain.width / (float)g_game_swapchain.height;
} else {
gameQuad.space = g_play_space;
gameQuad.eyeVisibility = XR_EYE_VISIBILITY_BOTH; gameQuad.eyeVisibility = XR_EYE_VISIBILITY_BOTH;
gameQuad.subImage.swapchain = g_game_swapchain.swapchain; gameQuad.subImage.swapchain = g_game_swapchain.swapchain;
gameQuad.subImage.imageRect.extent.width = g_game_swapchain.width; gameQuad.subImage.imageRect.extent.width = g_game_swapchain.width;
gameQuad.subImage.imageRect.extent.height = g_game_swapchain.height; gameQuad.subImage.imageRect.extent.height = g_game_swapchain.height;
gameQuad.pose.orientation.w = 1.0f; gameQuad.pose.orientation.w = 1.0f;
gameQuad.pose.position.z = -QUAD_DISTANCE_METERS; gameQuad.pose.position.z = -gameDistance;
gameQuad.size.width = QUAD_WIDTH_METERS; gameQuad.size.width = gameHalfWidth * 2.0f;
gameQuad.size.height = gameQuad.size.height = gameHalfHeight * 2.0f;
QUAD_WIDTH_METERS * (float)g_game_swapchain.height / (float)g_game_swapchain.width; }
const XrCompositionLayerBaseHeader *layers[5]; const XrCompositionLayerBaseHeader *layers[5];
uint32_t layerCount = 0; uint32_t layerCount = 0;
if (g_have_acquired_game_image) if (g_have_acquired_game_image)
layers[layerCount++] = (XrCompositionLayerBaseHeader *)&gameQuad; layers[layerCount++] = gameScreenCurved ? (XrCompositionLayerBaseHeader *)&gameCylinder
: (XrCompositionLayerBaseHeader *)&gameQuad;
XrCompositionLayerQuad menuQuad; XrCompositionLayerQuad menuQuad;
if (g_frame_should_render && xr_overlay_render_and_build_layer(g_menu_overlay, &menuQuad)) { if (g_frame_should_render && xr_overlay_render_and_build_layer(g_menu_overlay, &menuQuad)) {
menuQuad.space = g_local_space; menuQuad.space = g_play_space;
menuQuad.eyeVisibility = XR_EYE_VISIBILITY_BOTH; menuQuad.eyeVisibility = XR_EYE_VISIBILITY_BOTH;
layers[layerCount++] = (XrCompositionLayerBaseHeader *)&menuQuad; layers[layerCount++] = (XrCompositionLayerBaseHeader *)&menuQuad;
} }
@@ -410,7 +758,7 @@ void xr_frame_end(void) {
XrCompositionLayerQuad keyboardQuad; XrCompositionLayerQuad keyboardQuad;
if (g_frame_should_render && if (g_frame_should_render &&
xr_overlay_render_and_build_layer(g_keyboard_overlay, &keyboardQuad)) { xr_overlay_render_and_build_layer(g_keyboard_overlay, &keyboardQuad)) {
keyboardQuad.space = g_local_space; keyboardQuad.space = g_play_space;
keyboardQuad.eyeVisibility = XR_EYE_VISIBILITY_BOTH; keyboardQuad.eyeVisibility = XR_EYE_VISIBILITY_BOTH;
// Lets the keyboard sit on screen translucently while playing // Lets the keyboard sit on screen translucently while playing
// rather than fully hiding whatever's behind it - KeyboardOverlay // rather than fully hiding whatever's behind it - KeyboardOverlay
@@ -430,7 +778,7 @@ void xr_frame_end(void) {
XrCompositionLayerQuad beamQuad[2]; XrCompositionLayerQuad beamQuad[2];
for (int hand = 0; hand < 2; hand++) { for (int hand = 0; hand < 2; hand++) {
if (xr_input_get_beam_layer(hand, &beamQuad[hand])) { if (xr_input_get_beam_layer(hand, &beamQuad[hand])) {
beamQuad[hand].space = g_local_space; beamQuad[hand].space = g_play_space;
beamQuad[hand].eyeVisibility = XR_EYE_VISIBILITY_BOTH; beamQuad[hand].eyeVisibility = XR_EYE_VISIBILITY_BOTH;
beamQuad[hand].layerFlags = XR_COMPOSITION_LAYER_BLEND_TEXTURE_SOURCE_ALPHA_BIT; beamQuad[hand].layerFlags = XR_COMPOSITION_LAYER_BLEND_TEXTURE_SOURCE_ALPHA_BIT;
layers[layerCount++] = (XrCompositionLayerBaseHeader *)&beamQuad[hand]; layers[layerCount++] = (XrCompositionLayerBaseHeader *)&beamQuad[hand];
@@ -444,6 +792,8 @@ void xr_frame_end(void) {
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) {
@@ -453,6 +803,10 @@ void xr_shutdown(void) {
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_overlay_destroy(g_menu_overlay); xr_overlay_destroy(g_menu_overlay);
@@ -470,13 +824,56 @@ 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 = g_screen_distance_m = distance_m;
QUAD_WIDTH_METERS * 0.5f * (float)g_game_swapchain.height / (float)g_game_swapchain.width; }
// 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) { void xr_get_game_resolution(int *width, int *height) {
+89 -6
View File
@@ -62,17 +62,36 @@ bool xr_frame_begin(void);
// launcher and keyboard overlay quads via xr_overlay_render_and_build_layer() // launcher and keyboard overlay quads via xr_overlay_render_and_build_layer()
// for whichever of them is currently visible, and submits whichever of the // for whichever of them is currently visible, and submits whichever of the
// three quads actually rendered this frame as composition layers // three quads actually rendered this frame as composition layers
// positioned in front of the local reference space's origin, then ends the // positioned in front of the play space's origin (see
// XR frame. // 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, // The engine's current screen resolution (gScreenWide/gScreenHigh,
// InitMac.h) - not the game quad's own swapchain size, which is fixed at // InitMac.h) - not the game quad's own swapchain size, which is fixed at
@@ -83,6 +102,70 @@ void xr_get_game_quad_extent(float *distance_m, float *half_width_m, float *half
// swapchain. // swapchain.
void xr_get_game_resolution(int *width, int *height); void xr_get_game_resolution(int *width, int *height);
// Pixel size of the game quad's swapchain image, fixed at launch (0, 0 before
// xr_init()). The quad's aspect ratio is this size, so full-quad content
// drawn outside the engine's own frame path (the startup splash) is laid out
// against it.
void xr_get_game_swapchain_size(int *width, int *height);
// Exponentially-smoothed frame rate/frame time, updated once per real
// 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
} }
#endif #endif
@@ -59,11 +59,12 @@ public class KeyboardOverlay extends OverlayPanel {
private static final int TYPED_TEXT_HEIGHT = 100; private static final int TYPED_TEXT_HEIGHT = 100;
// Height of the title bar (buildTitleBar()) and width of its Close // Height of the title bar (buildTitleBar()) and width of its Close
// button - xr_input.c's TITLE_BAR_V_FRACTION/CLOSE_BUTTON_U_FRACTION // button - xr_input.c's KEYBOARD_TITLE_BAR_V_FRACTION/
// must be kept in sync with these (as fractions of WIDTH/HEIGHT above), // KEYBOARD_CLOSE_BUTTON_U_FRACTION must be kept in sync with these (as
// since the drag-handle-vs-Close-button-vs-rest-of-the-keyboard // fractions of WIDTH/HEIGHT above), since the drag-handle-vs-Close-
// decision happens entirely on the native side, before a hit ever // button-vs-rest-of-the-keyboard decision happens entirely on the
// reaches this class's own touch dispatch. // 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 TITLE_BAR_HEIGHT = 90;
private static final int CLOSE_BUTTON_WIDTH = 160; private static final int CLOSE_BUTTON_WIDTH = 160;
@@ -76,6 +77,17 @@ public class KeyboardOverlay extends OverlayPanel {
private TextView typedTextView; private TextView typedTextView;
private final StringBuilder typedText = new StringBuilder(); 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 - // Shift is a local-only layer toggle (never itself sent to the game -
// see updateShiftVisual()); Ctrl/Alt are one-shot "armed" modifiers, // see updateShiftVisual()); Ctrl/Alt are one-shot "armed" modifiers,
// sent to the game the moment they're pressed and released again as // sent to the game the moment they're pressed and released again as
@@ -142,6 +154,7 @@ public class KeyboardOverlay extends OverlayPanel {
@Override @Override
protected View buildContent() { protected View buildContent() {
charKeys = new ArrayList<>(); charKeys = new ArrayList<>();
currentLanguage = MenuOverlay.nativeGetLanguage();
LinearLayout panel = new LinearLayout(activity); LinearLayout panel = new LinearLayout(activity);
panel.setOrientation(LinearLayout.VERTICAL); panel.setOrientation(LinearLayout.VERTICAL);
@@ -214,26 +227,31 @@ public class KeyboardOverlay extends OverlayPanel {
// xr_input.c before a hit ever reaches here) plus a real Close button, // 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 // which is dispatched as an ordinary click like any other key. Its
// height and the Close button's width must stay in sync with // height and the Close button's width must stay in sync with
// xr_input.c's TITLE_BAR_V_FRACTION/CLOSE_BUTTON_U_FRACTION. // xr_input.c's KEYBOARD_TITLE_BAR_V_FRACTION/
// KEYBOARD_CLOSE_BUTTON_U_FRACTION.
private LinearLayout buildTitleBar() { private LinearLayout buildTitleBar() {
LinearLayout bar = new LinearLayout(activity); LinearLayout bar = new LinearLayout(activity);
bar.setOrientation(LinearLayout.HORIZONTAL); bar.setOrientation(LinearLayout.HORIZONTAL);
bar.setBackgroundColor(0xFF3A3A3A); // Matches MenuOverlay's own header bar style.
bar.setBackgroundColor(0xFF000080);
TextView label = new TextView(activity); titleLabel = new TextView(activity);
label.setText("Keyboard - drag here to move"); titleLabel.setText(Strings.get("keyboard_drag_title", currentLanguage));
label.setTextColor(0xFFFFFFFF); titleLabel.setTextColor(0xFFFFFFFF);
label.setTextSize(TypedValue.COMPLEX_UNIT_SP, 22); titleLabel.setTextSize(TypedValue.COMPLEX_UNIT_SP, 20);
label.setGravity(Gravity.CENTER_VERTICAL); titleLabel.setGravity(Gravity.CENTER_VERTICAL);
label.setPadding(24, 0, 0, 0); titleLabel.setPadding(24, 0, 0, 0);
bar.addView(label, new LinearLayout.LayoutParams( bar.addView(titleLabel, new LinearLayout.LayoutParams(
0, ViewGroup.LayoutParams.MATCH_PARENT, 1f)); 0, ViewGroup.LayoutParams.MATCH_PARENT, 1f));
Button close = new Button(activity); closeButton = new Button(activity);
close.setText("Close"); closeButton.setText(Strings.get("close", currentLanguage));
close.setOnClickListener(v -> nativeRequestClose()); closeButton.setOnClickListener(v -> nativeRequestClose());
close.setOnTouchListener(CLICK_ON_TOUCH_UP); closeButton.setOnTouchListener(CLICK_ON_TOUCH_UP);
bar.addView(close, closeButton.setBackground(buttonBackground());
closeButton.setTextColor(buttonTextColors());
registerHoverable(closeButton);
bar.addView(closeButton,
new LinearLayout.LayoutParams(CLOSE_BUTTON_WIDTH, ViewGroup.LayoutParams.MATCH_PARENT)); new LinearLayout.LayoutParams(CLOSE_BUTTON_WIDTH, ViewGroup.LayoutParams.MATCH_PARENT));
return bar; return bar;
@@ -253,6 +271,9 @@ public class KeyboardOverlay extends OverlayPanel {
row.setOrientation(LinearLayout.HORIZONTAL); row.setOrientation(LinearLayout.HORIZONTAL);
for (Object item : items) { for (Object item : items) {
Button key = new Button(activity); Button key = new Button(activity);
key.setBackground(buttonBackground());
key.setTextColor(buttonTextColors());
registerHoverable(key);
float weight; float weight;
if (item instanceof CharKey) { if (item instanceof CharKey) {
CharKey charKey = (CharKey) item; CharKey charKey = (CharKey) item;
@@ -517,6 +538,26 @@ public class KeyboardOverlay extends OverlayPanel {
// the keyboard (see this class's doc comment and xr_input.c). // the keyboard (see this class's doc comment and xr_input.c).
private static native void nativeRequestClose(); 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 // Called from xr_overlay.c's xr_overlay_init_jni(), on the render
// thread, right after it resolves this class via the activity's own // thread, right after it resolves this class via the activity's own
// ClassLoader (plain FindClass() can't see app classes from a thread // ClassLoader (plain FindClass() can't see app classes from a thread
File diff suppressed because it is too large Load Diff
@@ -1,13 +1,20 @@
package de.ladkau.questshock; package de.ladkau.questshock;
import android.app.Activity; import android.app.Activity;
import android.content.res.ColorStateList;
import android.graphics.Bitmap; import android.graphics.Bitmap;
import android.graphics.Canvas; import android.graphics.Canvas;
import android.graphics.Paint; 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.os.SystemClock;
import android.view.MotionEvent; import android.view.MotionEvent;
import android.view.View; import android.view.View;
import java.nio.ByteBuffer; 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 * Shared machinery for an off-screen, never-attached-to-a-window View tree
@@ -59,11 +66,59 @@ abstract class OverlayPanel {
return true; 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; protected final Activity activity;
private final int width; private final int width;
private final int height; private final int height;
private final View root; 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), // contentBitmap holds just the panel's own rendered content (no cursor),
// redrawn only when that content actually changes (redrawContent(), via // redrawn only when that content actually changes (redrawContent(), via
// handleTouch() or a subclass's own key handling) via root.draw() - a // handleTouch() or a subclass's own key handling) via root.draw() - a
@@ -183,9 +238,95 @@ abstract class OverlayPanel {
cursorVisible = visible; cursorVisible = visible;
cursorX = x; cursorX = x;
cursorY = y; 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(); 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) { private void handleTouch(float u, float v, boolean down) {
float x = u * width; float x = u * width;
float y = v * height; float y = v * height;
@@ -5,6 +5,8 @@ import android.app.AlertDialog;
import android.content.Context; import android.content.Context;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
import android.content.res.AssetManager; import android.content.res.AssetManager;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.os.Bundle; import android.os.Bundle;
import android.util.Log; import android.util.Log;
import android.view.SurfaceHolder; import android.view.SurfaceHolder;
@@ -18,7 +20,7 @@ import org.libsdl.app.SDLActivity;
import org.libsdl.app.SDLSurface; import org.libsdl.app.SDLSurface;
/** /**
* Prepares /sdcard/questshock/ the way Shockolate's plain relative-path * Prepares /sdcard/QuestShock/ the way Shockolate's plain relative-path
* file I/O ("res/data/...", confirmed via grep - none of it goes through * file I/O ("res/data/...", confirmed via grep - none of it goes through
* SDL_RWops, so Android's asset-manager fallback for SDL_RWFromFile * SDL_RWops, so Android's asset-manager fallback for SDL_RWFromFile
* doesn't apply here) expects to find it - see android/engine-patches/ for * doesn't apply here) expects to find it - see android/engine-patches/ for
@@ -39,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())
@@ -57,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[] {
@@ -143,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
@@ -172,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;
@@ -204,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();
@@ -232,7 +272,7 @@ public class QuestShockActivity extends SDLActivity {
} }
} }
private void copyAssetDir(String assetDir, File destDir) { private void copyAssetDir(String assetDir, File destDir, boolean overwrite) {
AssetManager assets = getAssets(); AssetManager assets = getAssets();
String[] entries; String[] entries;
try { try {
@@ -246,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];
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 16 KiB

+1 -1
View File
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">System Shock</string> <string name="app_name">QuestShock</string>
</resources> </resources>
+12 -1
View File
@@ -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'
} }
} }
@@ -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");
+4 -3
View File
@@ -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
+1 -1
View File
@@ -124,7 +124,7 @@ 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 mingw-w64 zip \ openjdk-17-jdk-headless unzip mingw-w64 zip \
+22 -5
View File
@@ -24,16 +24,33 @@ VERSION="$("$REPO_ROOT/build-image/version.sh")"
VERSION_CODE="$(git -C "$REPO_ROOT" rev-list --count HEAD)" VERSION_CODE="$(git -C "$REPO_ROOT" rev-list --count HEAD)"
echo "Version: $VERSION (versionCode $VERSION_CODE)" echo "Version: $VERSION (versionCode $VERSION_CODE)"
echo "== Building the APK (gradlew assembleDebug) ==" # A release APK is built and signed when QUESTSHOCK_KEYSTORE is set (with
# QUESTSHOCK_KEYSTORE_PASSWORD, and optionally QUESTSHOCK_KEY_ALIAS,
# default "questshock", and QUESTSHOCK_KEY_PASSWORD, default the keystore
# password). Without it the APK is a debug build, signed with a per-machine
# debug key.
if [ -n "${QUESTSHOCK_KEYSTORE:-}" ]; then
[ -f "$QUESTSHOCK_KEYSTORE" ] || { echo "BUILD FAIL: QUESTSHOCK_KEYSTORE ($QUESTSHOCK_KEYSTORE) does not exist" >&2; exit 1; }
[ -n "${QUESTSHOCK_KEYSTORE_PASSWORD:-}" ] || { echo "BUILD FAIL: QUESTSHOCK_KEYSTORE_PASSWORD is not set" >&2; exit 1; }
GRADLE_TASK=assembleRelease
APK="$ANDROID_DIR/app/build/outputs/apk/release/app-release.apk"
PKG_SUFFIX=""
else
echo "== QUESTSHOCK_KEYSTORE is not set: building a debug APK, not for distribution =="
GRADLE_TASK=assembleDebug
APK="$ANDROID_DIR/app/build/outputs/apk/debug/app-debug.apk"
PKG_SUFFIX="-debug"
fi
echo "== Building the APK (gradlew $GRADLE_TASK) =="
cd "$ANDROID_DIR" cd "$ANDROID_DIR"
./gradlew --no-daemon assembleDebug \ ./gradlew --no-daemon "$GRADLE_TASK" \
"-PquestshockVersionName=$VERSION" "-PquestshockVersionCode=$VERSION_CODE" "-PquestshockVersionName=$VERSION" "-PquestshockVersionCode=$VERSION_CODE"
APK="$ANDROID_DIR/app/build/outputs/apk/debug/app-debug.apk" [ -f "$APK" ] || { echo "BUILD FAIL: $APK not found after $GRADLE_TASK" >&2; exit 1; }
[ -f "$APK" ] || { echo "BUILD FAIL: $APK not found after assembleDebug" >&2; exit 1; }
mkdir -p "$REPO_ROOT/dist" mkdir -p "$REPO_ROOT/dist"
PKG_NAME="questshock-$VERSION-android-arm64.apk" PKG_NAME="questshock-$VERSION-android-arm64$PKG_SUFFIX.apk"
cp "$APK" "$REPO_ROOT/dist/$PKG_NAME" cp "$APK" "$REPO_ROOT/dist/$PKG_NAME"
echo "== Done ==" echo "== Done =="
+9 -2
View File
@@ -5,8 +5,9 @@
# already has working WIN32/MINGW branches (the upstream Shockolate # already has working WIN32/MINGW branches (the upstream Shockolate
# project's own build_win64.sh/appveyor.yml build the same way, natively # 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 # on Windows via Git Bash/MinGW; this cross-compiles the same branches
# fully offline and reproducibly from Linux instead), so no source # fully offline and reproducibly from Linux instead), so building it needs
# patching is needed, unlike the Quest build's android/engine-patches/. # 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/ # Builds from a scratch copy of engine/ (like android/engine-patches/
# does for the Quest build) rather than engine/ itself: its BUNDLED # does for the Quest build) rather than engine/ itself: its BUNDLED
@@ -47,6 +48,12 @@ fi
rm -rf "$(dirname "$SCRATCH_ENGINE")/.symlink-test-dir" rm -rf "$(dirname "$SCRATCH_ENGINE")/.symlink-test-dir"
cp "${CP_FLAGS[@]}" "$REPO_ROOT/engine" "$SCRATCH_ENGINE" 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" cd "$SCRATCH_ENGINE"
# If engine/ has ever been built in-source directly (`make dist`/`make # If engine/ has ever been built in-source directly (`make dist`/`make
+39 -3
View File
@@ -8,16 +8,52 @@
# 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
# ENGINE_DEBUG_TOOLS=1 (set by the Makefile's dist_debug target) additionally
# applies engine-debug-tools/engine-patches/ - debug-only instrumentation
# (see root README.md's "Debugging" section) that stays out of every normal
# build.
if [ -n "${ENGINE_DEBUG_TOOLS:-}" ]; then
echo "== ENGINE_DEBUG_TOOLS set - also applying engine-debug-tools/engine-patches/ =="
for p in "$REPO_ROOT"/engine-debug-tools/engine-patches/*.patch; do
patch -p1 -d "$SCRATCH_ENGINE" < "$p"
done
fi
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
+4 -1
View File
@@ -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
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Builds dump2png (see dump2png/main.go) inside modding/mdlp-image's Go
# toolchain - dump2png has no cgo dependencies of its own, so it reuses that
# image rather than needing one of its own, same reasoning as
# modding/gen_animgallery.sh.
#
# Usage: ./build-dump2png.sh <output-path>
# <output-path> is relative to the repository root (typically
# dist/dump2png - see the root Makefile's dist_debug target and
# README.md's "Debugging" section) - it's written on both sides of the
# bind mount below, so an absolute or ../-escaping path won't resolve to
# the same file on the host afterward.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
REPO_ROOT="$(cd .. && pwd)"
[ "$#" -eq 1 ] || { echo "usage: $0 <output-path-relative-to-repo-root>" >&2; exit 1; }
OUT_PATH="$1"
case "$OUT_PATH" in
/*) echo "PREFLIGHT FAIL: <output-path> must be relative to the repo root, not absolute: $OUT_PATH" >&2; exit 1 ;;
esac
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 "$REPO_ROOT/registry.env" ]; then
# shellcheck disable=SC1091
source "$REPO_ROOT/registry.env"
IMAGE="${MDLP_REGISTRY_IMAGE:-$IMAGE}"
fi
IMAGE_TAG="$(<"$REPO_ROOT/modding/mdlp-image/VERSION")"
[ -n "$IMAGE_TAG" ] || fail "modding/mdlp-image/VERSION is empty"
IMAGE="$IMAGE:$IMAGE_TAG"
docker image inspect "$IMAGE" >/dev/null 2>&1 \
|| fail "$IMAGE not found locally - run modding/mdlp-image.sh first"
docker run --rm \
-v "$REPO_ROOT:/workspace" \
-w /workspace \
-e HOST_UID="$(id -u)" \
-e HOST_GID="$(id -g)" \
"$IMAGE" \
bash -c '
set -euo pipefail
# dump2png builds against ../../modding/src/hacked via its own
# go.mod replace (matching that relative depth exactly, since
# engine-debug-tools/ sits next to modding/ at the repo root) -
# stage a patched copy (see modding/src/hacked-patches/) at that
# same relative position rather than building against the
# unpatched vendored source.
SCRATCH_DIR="engine-debug-tools/build/dump2png-src"
rm -rf "$SCRATCH_DIR"
mkdir -p "$SCRATCH_DIR/engine-debug-tools"
bash modding/mdlp-image/stage-hacked.sh modding "$SCRATCH_DIR/modding/src/hacked"
cp -a engine-debug-tools/dump2png "$SCRATCH_DIR/engine-debug-tools/dump2png"
mkdir -p "$(dirname "/workspace/$1")"
(cd "$SCRATCH_DIR/engine-debug-tools/dump2png" && go build -o "/workspace/$1" .)
' _ "$OUT_PATH"
echo "Built: $OUT_PATH"
+12
View File
@@ -0,0 +1,12 @@
module questshock/dump2png
go 1.25.0
// hacked/ is vendored under modding/src/ (see ../../modding/README.md);
// dump2png reuses its resource/palette reading 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 => ../../modding/src/hacked
+10
View File
@@ -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=
+105
View File
@@ -0,0 +1,105 @@
// dump2png converts the raw indexed-pixel frame dumps written by the
// engine's debug dump hotkey (see ../engine-patches/ and the root
// README.md's "Debugging" section) into viewable PNGs, using the real game
// palette - see that same README section for the dump file format.
package main
import (
"encoding/binary"
"fmt"
"image"
"image/color"
"image/png"
"os"
"path/filepath"
"sort"
"strings"
"github.com/inkyblackness/hacked/ss1/content/bitmap"
"github.com/inkyblackness/hacked/ss1/resource"
"github.com/inkyblackness/hacked/ss1/world"
)
func main() {
if len(os.Args) != 4 {
fmt.Fprintln(os.Stderr, "usage: dump2png <game-data-dir> <dumps-dir> <out-dir>")
os.Exit(1)
}
dataDir, inDir, outDir := os.Args[1], os.Args[2], os.Args[3]
entries, err := os.ReadDir(dataDir)
if err != nil {
panic(err)
}
merged := &world.ManifestEntry{Origin: []string{dataDir}, ID: dataDir}
for _, e := range entries {
if e.IsDir() {
continue
}
ext := strings.ToLower(filepath.Ext(e.Name()))
if ext != ".res" && ext != ".dat" {
continue
}
fe, err := world.NewManifestEntryFrom([]string{filepath.Join(dataDir, e.Name())})
if err != nil {
continue
}
merged.Resources = append(merged.Resources, fe.Resources...)
}
sort.Slice(merged.Resources, func(i, j int) bool { return merged.Resources[i].ID < merged.Resources[j].ID })
mod := world.NewMod(func([]resource.ID, []resource.ID) {}, func() {})
if err := mod.World().InsertEntry(0, merged); err != nil {
panic(err)
}
pal, err := bitmap.NewPaletteCache(mod).Palette(resource.KeyOf(0x02BC, resource.LangDefault, 0))
if err != nil {
panic(err)
}
colors := pal.ColorPalette(false)
if err := os.MkdirAll(outDir, 0o755); err != nil {
panic(err)
}
files, err := os.ReadDir(inDir)
if err != nil {
panic(err)
}
for _, fi := range files {
if !strings.HasSuffix(fi.Name(), ".raw") {
continue
}
raw, err := os.ReadFile(filepath.Join(inDir, fi.Name()))
if err != nil {
fmt.Println("skip", fi.Name(), err)
continue
}
if len(raw) < 4 {
continue
}
w := int(binary.LittleEndian.Uint16(raw[0:2]))
h := int(binary.LittleEndian.Uint16(raw[2:4]))
pixels := raw[4:]
if len(pixels) < w*h {
fmt.Println("truncated", fi.Name())
continue
}
img := image.NewRGBA(image.Rect(0, 0, w, h))
for i := 0; i < w*h; i++ {
r, g, b, _ := colors[pixels[i]].RGBA()
img.Set(i%w, i/w, color.RGBA{R: byte(r >> 8), G: byte(g >> 8), B: byte(b >> 8), A: 0xff})
}
outPath := filepath.Join(outDir, strings.TrimSuffix(fi.Name(), ".raw")+".png")
of, err := os.Create(outPath)
if err != nil {
fmt.Println("create failed", outPath, err)
continue
}
err = png.Encode(of, img)
of.Close()
if err != nil {
fmt.Println("encode failed", outPath, err)
continue
}
fmt.Println("wrote", outPath, w, h)
}
}
@@ -0,0 +1,153 @@
--- a/src/GameSrc/objsim.c
+++ b/src/GameSrc/objsim.c
@@ -26,8 +26,10 @@
// Object simulator code for Citadel
#define __OBJSIM_SRC
-#include <string.h>
+#include <stdio.h>
#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
#include "Shock.h"
@@ -1924,6 +1926,93 @@
*h = s_critterNativeH[id][frame];
}
+// Resource id of the last-drawn critter animation, updated every frame from
+// gameobj.c's FAUBJ_CRIT case - read by debug_dump_critter_animation() on
+// the debug dump hotkey (see engine-debug-tools/, root README.md's
+// "Debugging" section). Debug-build only: see
+// engine-debug-tools/engine-patches/.
+static Id s_lastCritterId = 0;
+
+void debug_track_critter_draw(Id id) { s_lastCritterId = id; }
+
+// Dumps every frame of the last-drawn critter's current animation (the
+// whole resource id, not just the currently-visible frame) as raw
+// palette-index bytes, one file per frame, independent of the renderer's
+// own texture/GL state - lets a corrupted frame be told apart from
+// corruption introduced later, in rendering. Format: 2-byte LE width,
+// 2-byte LE height, then width*height index bytes - converted to a viewable
+// image by engine-debug-tools/dump2png, not here, to avoid this debug path
+// depending on the renderer's own palette/SDL state.
+void debug_dump_critter_animation(void) {
+ RefTable *prt;
+ int numFrames, frame;
+ char path[256];
+ FILE *f;
+
+ if (s_lastCritterId == 0) {
+ fprintf(stderr, "[debug dump] no critter drawn yet\n");
+ return;
+ }
+
+ mkdir("dumps", 0755); // ignore EEXIST - fopen below reports any real failure
+
+ prt = (RefTable *)ResLock(s_lastCritterId);
+ if (prt == NULL) {
+ fprintf(stderr, "[debug dump] ResLock(%d) failed\n", (int)s_lastCritterId);
+ return;
+ }
+ numFrames = prt->numRefs;
+ ResUnlock(s_lastCritterId);
+
+ fprintf(stderr, "[debug dump] id=%d numFrames=%d\n", (int)s_lastCritterId, numFrames);
+
+ for (frame = 0; frame < numFrames; frame++) {
+ FrameDesc *fd;
+ grs_bitmap decoded;
+ uchar *bits;
+ short w, h;
+
+ fd = (FrameDesc *)RefLock(MKREF(s_lastCritterId, frame));
+ if (fd == NULL) {
+ fprintf(stderr, "[debug dump] RefLock frame %d failed\n", frame);
+ continue;
+ }
+
+ if (fd->bm.type == BMT_RSD8) {
+ if (gr_rsd8_convert(&fd->bm, &decoded) != 0 /* GR_UNPACK_RSD8_OK */) {
+ fprintf(stderr, "[debug dump] gr_rsd8_convert frame %d failed\n", frame);
+ RefUnlock(MKREF(s_lastCritterId, frame));
+ continue;
+ }
+ bits = decoded.bits;
+ w = decoded.w;
+ h = decoded.h;
+ } else {
+ bits = fd->bm.bits;
+ w = fd->bm.w;
+ h = fd->bm.h;
+ }
+
+ snprintf(path, sizeof(path), "dumps/id%d_frame%d.raw", (int)s_lastCritterId, frame);
+ f = fopen(path, "wb");
+ if (f != NULL) {
+ uchar hdr[4];
+ hdr[0] = (uchar)(w & 0xFF);
+ hdr[1] = (uchar)((w >> 8) & 0xFF);
+ hdr[2] = (uchar)(h & 0xFF);
+ hdr[3] = (uchar)((h >> 8) & 0xFF);
+ fwrite(hdr, 1, 4, f);
+ fwrite(bits, 1, (size_t)w * (size_t)h, f);
+ fclose(f);
+ fprintf(stderr, "[debug dump] wrote %s (%dx%d)\n", path, (int)w, (int)h);
+ } else {
+ fprintf(stderr, "[debug dump] failed to open %s for writing\n", path);
+ }
+
+ RefUnlock(MKREF(s_lastCritterId, frame));
+ }
+}
+
errtype obj_set_secondary_properties() {
char i, j;
RefTable *prt;
--- a/src/GameSrc/Headers/objsim.h
+++ b/src/GameSrc/Headers/objsim.h
@@ -109,6 +109,10 @@
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 frame, short *w, short *h);
+// Debug-build only - see engine-debug-tools/, root README.md's "Debugging"
+// section.
+void debug_track_critter_draw(Id id);
+void debug_dump_critter_animation(void);
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
+++ b/src/GameSrc/gameobj.c
@@ -1019,6 +1019,7 @@
{
short native_w = 0, native_h = 0;
get_critter_native_size(REFID(ref), REFINDEX(ref), &native_w, &native_h);
+ debug_track_critter_draw(REFID(ref));
_fr_draw_bitmap(tpdata, _fdt_dist, 0, anch.ul.x, anch.ul.y, native_w, native_h);
}
release_critter_bitmap_fast(ref);
--- a/src/Libraries/INPUT/Source/sdl_events.c
+++ b/src/Libraries/INPUT/Source/sdl_events.c
@@ -481,6 +481,19 @@
SDL_Event ev;
while (SDL_PollEvent(&ev)) {
+ // Debug-build only (engine-debug-tools/, root README.md's
+ // "Debugging" section): K dumps every frame of the last-drawn
+ // critter's current animation to dumps/. Checked here, before the
+ // switch below dispatches on event type at all, so it can never
+ // collide with an existing game keybinding - a plain letter rather
+ // than Insert, which some keyboards don't send at all (compact
+ // layouts, or routed through Fn).
+ if (ev.type == SDL_KEYDOWN && ev.key.keysym.sym == SDLK_k) {
+ extern void debug_dump_critter_animation(void);
+ debug_dump_critter_animation();
+ continue;
+ }
+
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,166 @@
--- a/src/Libraries/2D/Source/RSD/rsdcvt.c
+++ b/src/Libraries/2D/Source/RSD/rsdcvt.c
@@ -37,6 +37,7 @@
*
*/
#include <assert.h>
+#include <stdio.h>
#include <string.h>
#include "grs.h"
#include "bitmap.h"
@@ -47,6 +48,14 @@
uchar *grd_unpack_buf=NULL;
+// Set by debug_dump_critter_animation() (engine-debug-tools/, root
+// README.md's "Debugging" section) around its own gr_rsd8_convert() calls,
+// so a trace of this decode's internals - which path ran, whether it wrote
+// fewer bytes than the bitmap's own area, and (gr_rsd8_unpack(),
+// RSDUnpack.c) each opcode consumed - prints only for those calls, not
+// every RSD8 decode in the game. Debug-build only.
+int qs_debug_rsd8_trace = 0;
+
/*************************************************/
/* Puts 0's in place of skips and pads with 0's. */
/* i.e., grd_unpack_buf is entirely overwritten. */
@@ -138,6 +147,15 @@
}
}
rsd_done:
+ if (qs_debug_rsd8_trace) {
+ long written = (long)(p_dst - dbm->bits);
+ long expected = (long)dbm->row * (long)dbm->h;
+ fprintf(stderr,
+ "[debug rsd8] path=%s sbm(w=%d h=%d row=%d) dbm(row=%d h=%d) "
+ "written=%ld expected=%ld short_by=%ld\n",
+ (dbm->w == dbm->row) ? "fast" : "slow", sbm->w, sbm->h, sbm->row,
+ dbm->row, dbm->h, written, expected, expected - written);
+ }
if ((over_run = (dbm->bits+dbm->row*dbm->h)-p_dst)) {
assert(over_run > 0);
LG_memset (p_dst, kSkipColor, over_run);
--- a/src/Libraries/2D/Source/RSD/RSDUnpack.c
+++ b/src/Libraries/2D/Source/RSD/RSDUnpack.c
@@ -22,6 +22,7 @@
//
#include "grs.h"
+#include <stdio.h>
#include <stdbool.h>
//----------------------------------------------------------------------------
@@ -34,6 +35,10 @@
short count,count2;
ushort longcode;
uint32_t longval, *longdest, *longsrc;
+
+ extern int qs_debug_rsd8_trace; // rsdcvt.c - engine-debug-tools/, root README.md's "Debugging" section
+ uchar *src_start = src, *dest_start = dest;
+ long op_index = 0;
do
{
@@ -42,6 +47,7 @@
{
count = *(src++); // get count
val = *(src++); // get val
+ if (qs_debug_rsd8_trace) fprintf(stderr, "[debug rsd8 op] #%ld src_off=%ld dest_off=%ld type=run count=%d val=%d\n", op_index++, (long)(src-src_start), (long)(dest-dest_start), (int)count, (int)val);
if (count>=kMinLongLoop) // if at least kMinLongLoop bytes, do long word stuff
{
@@ -63,6 +69,7 @@
else if (code<0x80) // dump (copy) bytes
{
count = code;
+ if (qs_debug_rsd8_trace) fprintf(stderr, "[debug rsd8 op] #%ld src_off=%ld dest_off=%ld type=dump count=%d\n", op_index++, (long)(src-src_start), (long)(dest-dest_start), (int)count);
if (code>=kMinLongLoop) // if at least kMinLongLoop bytes, do long word stuff
{
count2 = count>>2;
@@ -84,6 +91,7 @@
{
count = code & 0x007f; // clear high byte
val = longval = 0L;
+ if (qs_debug_rsd8_trace) fprintf(stderr, "[debug rsd8 op] #%ld src_off=%ld dest_off=%ld type=skip count=%d\n", op_index++, (long)(src-src_start), (long)(dest-dest_start), (int)count);
if (count>=kMinLongLoop) // if at least kMinLongLoop bytes, do long word stuff
{
@@ -105,11 +113,15 @@
longcode = * (ushort *) src;
src += 2L;
- if (!longcode) break; // done?
+ if (!longcode) {
+ if (qs_debug_rsd8_trace) fprintf(stderr, "[debug rsd8 op] #%ld src_off=%ld dest_off=%ld type=terminator\n", op_index++, (long)(src-src_start), (long)(dest-dest_start));
+ break; // done?
+ }
else if (longcode<0x8000) // skip (zero)
{
count = longcode;
val = longval = 0L;
+ if (qs_debug_rsd8_trace) fprintf(stderr, "[debug rsd8 op] #%ld src_off=%ld dest_off=%ld type=longskip count=%d\n", op_index++, (long)(src-src_start), (long)(dest-dest_start), (int)count);
if (count>=kMinLongLoop) // if at least kMinLongLoop bytes, do long word stuff
{
@@ -129,6 +141,7 @@
else if (longcode<0xC000) // dump (copy)
{
count = longcode & 0x7fff; // clear high bit
+ if (qs_debug_rsd8_trace) fprintf(stderr, "[debug rsd8 op] #%ld src_off=%ld dest_off=%ld type=longdump count=%d\n", op_index++, (long)(src-src_start), (long)(dest-dest_start), (int)count);
if (count>=kMinLongLoop) // if at least kMinLongLoop bytes, do long word stuff
{
@@ -151,6 +164,7 @@
{
count = longcode & 0x3fff;
val = *(src++); // get val
+ if (qs_debug_rsd8_trace) fprintf(stderr, "[debug rsd8 op] #%ld src_off=%ld dest_off=%ld type=longrun count=%d val=%d\n", op_index++, (long)(src-src_start), (long)(dest-dest_start), (int)count, (int)val);
if (count>=kMinLongLoop) // if at least kMinLongLoop bytes, do long word stuff
{
--- a/src/GameSrc/objsim.c
+++ b/src/GameSrc/objsim.c
@@ -1979,11 +1979,43 @@
}
if (fd->bm.type == BMT_RSD8) {
- if (gr_rsd8_convert(&fd->bm, &decoded) != 0 /* GR_UNPACK_RSD8_OK */) {
+ extern int qs_debug_rsd8_trace; // rsdcvt.c - engine-debug-tools/, root README.md's "Debugging" section
+ uchar *first_copy;
+ size_t nbytes;
+ int rsd8_ok;
+
+ qs_debug_rsd8_trace = 1;
+ rsd8_ok = gr_rsd8_convert(&fd->bm, &decoded) == 0 /* GR_UNPACK_RSD8_OK */;
+ qs_debug_rsd8_trace = 0;
+ if (!rsd8_ok) {
fprintf(stderr, "[debug dump] gr_rsd8_convert frame %d failed\n", frame);
RefUnlock(MKREF(s_lastCritterId, frame));
continue;
}
+
+ // Re-decode the same compressed source (fd->bm.bits, unchanged
+ // by the first call - only grd_unpack_buf, the shared
+ // decode-output buffer, gets overwritten) a second time and
+ // compare: decode runs back to back on this single thread, with
+ // nothing else able to touch fd->bm.bits or grd_unpack_buf in
+ // between, so a byte-for-byte match here means any garbled
+ // pixels are a deterministic property of this exact compressed
+ // stream (bad source data, or a decoder bug it triggers), not a
+ // one-off from a resource-cache slot being reused mid-decode.
+ nbytes = (size_t)decoded.w * (size_t)decoded.h;
+ first_copy = malloc(nbytes);
+ if (first_copy != NULL) {
+ memcpy(first_copy, decoded.bits, nbytes);
+
+ qs_debug_rsd8_trace = 1;
+ gr_rsd8_convert(&fd->bm, &decoded);
+ qs_debug_rsd8_trace = 0;
+
+ fprintf(stderr, "[debug dump] frame %d repeat-decode of bm.bits=%p %s\n", frame,
+ (void *)fd->bm.bits, memcmp(first_copy, decoded.bits, nbytes) == 0 ? "identical" : "DIFFERS");
+ free(first_copy);
+ }
+
bits = decoded.bits;
w = decoded.w;
h = decoded.h;
@@ -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,312 @@
--- a/src/GameSrc/objsim.c
+++ b/src/GameSrc/objsim.c
@@ -1867,6 +1867,63 @@
return (OK);
}
+// Every critter view's base-game bitmap size, keyed by resource ID and
+// frame index within that animation - 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.
+// Keyed per frame, not just per view: an animation's bounding box commonly
+// changes size between poses, same as the base game's own art does, so a
+// single per-view size doesn't fit every frame of it - using one here left
+// every frame but the first using the wrong override, visibly corrupting
+// the sprite's UV mapping (g3_bitmap_common() uses this same size for
+// texture coordinates, not just world-space extent) once a mod's per-frame
+// sizes diverge from each other. MAX_CRITTER_FRAMES comfortably covers any
+// animation in the base game's own data; cache_critter_native_size() clamps
+// to it defensively rather than overrunning the array if that ever changes.
+// Id is uint16_t, so a flat array indexed by it needs no bounds check.
+#define MAX_CRITTER_FRAMES 32
+static short s_critterNativeW[65536][MAX_CRITTER_FRAMES];
+static short s_critterNativeH[65536][MAX_CRITTER_FRAMES];
+
+static void cache_critter_native_size(Id id) {
+ RefTable *prt;
+ FrameDesc *f;
+ int numFrames, frame;
+ if (s_critterNativeW[id][0] != 0)
+ return;
+ prt = (RefTable *)ResLock(id);
+ if (prt == NULL)
+ return;
+ numFrames = prt->numRefs;
+ ResUnlock(id);
+ if (numFrames > MAX_CRITTER_FRAMES)
+ numFrames = MAX_CRITTER_FRAMES;
+ for (frame = 0; frame < numFrames; frame++) {
+ f = (FrameDesc *)RefLock(MKREF(id, frame));
+ if (f == NULL)
+ continue;
+ s_critterNativeW[id][frame] = f->bm.w;
+ s_critterNativeH[id][frame] = f->bm.h;
+ RefUnlock(MKREF(id, frame));
+ }
+}
+
+// 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/frame was never
+// cached.
+void get_critter_native_size(Id id, short frame, short *w, short *h) {
+ if ((frame < 0) || (frame >= MAX_CRITTER_FRAMES) || (s_critterNativeW[id][frame] == 0))
+ return;
+ *w = s_critterNativeW[id][frame];
+ *h = s_critterNativeH[id][frame];
+}
+
errtype obj_set_secondary_properties() {
char i, j;
RefTable *prt;
@@ -1878,6 +1935,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
+++ b/src/GameSrc/Headers/objsim.h
@@ -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 frame, 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
+++ b/src/GameSrc/gameobj.c
@@ -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), REFINDEX(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
+++ b/src/Libraries/3D/Source/Bitmap.c
@@ -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));
@@ -176,6 +187,7 @@
grs_vertex *tempG1, *tempG2;
fix tempResult;
int32_t bm_w, bm_h;
+ int32_t tex_w, tex_h;
fix dx, dy;
// MLA- these were globals, I made them locals for PPC speed, they aren't
@@ -185,6 +197,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 +230,23 @@
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, for the on-screen quad's world-space size
+ // only. tex_w/tex_h stay the bitmap's own real dimensions always: the
+ // quad's u/v bounds (below) are pixel coordinates into bm's own actual
+ // pixel data - the software renderer's scanline texture mapper
+ // (h_map(), gentm.c) interpolates and samples them literally, so
+ // overriding them to a smaller size here would make it only ever read
+ // part of a bitmap bigger than that override (such as an upscaled mod's
+ // replacement art), cropping the rest. OpenGL doesn't read u/v from here
+ // at all (opengl_bitmap() always maps the whole real texture over the
+ // quad), which is why this only ever showed up in the software
+ // renderer.
+ bm_w = w_override ? w_override : bm->w;
+ bm_h = h_override ? h_override : bm->h;
+ tex_w = bm->w;
+ tex_h = bm->h;
tempL = p->gZ; // mov eax,[edi].z
tempL <<= 8; // sal eax,8 ;should be
@@ -299,7 +334,7 @@
tempG1->y = tempL2;
// vpl[1][2] = bitmap.w-1
- tempG1->u = (bm_w - 1) << 16;
+ tempG1->u = (tex_w - 1) << 16;
// vpl[1][3] = 0
tempG1->v = 0;
@@ -326,10 +361,10 @@
tempG1->y = tempL2;
// vpl[2][2] = bitmap.w-1
- tempG1->u = (bm_w - 1) << 16;
+ tempG1->u = (tex_w - 1) << 16;
// vpl[2][3] = bitmap.h-1
- tempG1->v = (bm_h - 1) << 16;
+ tempG1->v = (tex_h - 1) << 16;
// vpl[2][4] undefined
@@ -356,7 +391,7 @@
tempG1->u = 0;
// vpl[3][3] = bitmap.h
- tempG1->v = bm_h << 16;
+ tempG1->v = tex_h << 16;
// vpl[3][4] undefined
@@ -380,11 +415,13 @@
if (dy > dx)
dx = dy; // get max value in dx
- // shift down because bm_w isn't fixed, and we only double when twice the size
+ // shift down because tex_w isn't fixed, and we only double when twice the size
dx >>= 0x11;
// make sure doubled bitmap won't overflow unpack buffer (that's roughly 1/5 of 64k)
- if ((bm_w <= dx) && (bm_w * bm_h <= 12000)) {
+ // - against the bitmap's own real size (tex_w/tex_h), same as the u/v this doubles
+ // just below: both operate on bm's actual pixel data, not the on-screen override.
+ if ((tex_w <= dx) && (tex_w * tex_h <= 12000)) {
// first fix all u's and v's
for (tempL = 0; tempL < 4; tempL++) {
_g3d_bitmap_poly[tempL]->u <<= 1;
--- a/src/Libraries/3D/Source/3d.h
+++ b/src/Libraries/3D/Source/3d.h
@@ -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,84 @@
--- a/src/MacSrc/OpenGL.cc
+++ b/src/MacSrc/OpenGL.cc
@@ -41,6 +41,7 @@
#include <map>
#include <sstream>
+#include <tuple>
struct CachedTexture {
SDL_Surface *bitmap;
@@ -83,8 +84,23 @@
static SDL_Palette *opaquePalette;
static SDL_Palette *transparentPalette;
-// Texture cache to keep SDL surfaces and GL textures in memory
-static std::map<uint64_t, CachedTexture> texturesByBitsPtr;
+// Texture cache to keep SDL surfaces and GL textures in memory. Keyed by the
+// full (bits, w, h) tuple, not a packed uint64_t - bm->bits is a full
+// pointer, and a 64-bit Linux heap/mmap address routinely has non-zero bits
+// in the 32-47 range, so OR-packing w/h on top of it (as this used to do)
+// can collide: a genuinely different (bits, w, h) triple produces the same
+// packed key as a cached entry for a different bitmap, so a lookup reports a
+// false "hit" and set_texture() reuses that entry's SDL surfaces - sized for
+// the old entry's own dimensions - for an SDL_memmove and glTexImage2D using
+// the *current* bitmap's byte count, corrupting the result with a buffer
+// overflow or leftover stale pixel data from whatever was cached before.
+// bm->bits itself is a recycled slot in the resource system's own bounded
+// LRU cache, not a stable per-resource address, so a collision is a real
+// risk in practice, not just theoretical - most noticeable with content
+// whose per-frame bitmap dimensions vary a lot (critter animations with
+// different poses per frame, especially after an AI upscale mod re-exports
+// each frame at its own independently-upscaled size).
+static std::map<std::tuple<void *, int, int>, CachedTexture> texturesByBitsPtr;
static float view_scale;
static int phys_width;
@@ -577,7 +593,7 @@
bind_texture(toCache.texture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, bm->w, bm->h, 0, GL_RGBA, GL_UNSIGNED_BYTE, toCache.converted->pixels);
- texturesByBitsPtr[(uint64_t)bm->bits | ((uint64_t)bm->w << 32u) | ((uint64_t)bm->h << 48u)] = toCache;
+ texturesByBitsPtr[std::make_tuple(bm->bits, (int)bm->w, (int)bm->h)] = toCache;
return true;
}
@@ -589,7 +605,7 @@
}
static CachedTexture *opengl_get_texture(grs_bitmap *bm) {
- auto iter = texturesByBitsPtr.find((uint64_t)bm->bits | ((uint64_t)bm->w << 32u) | ((uint64_t)bm->h << 48u));
+ auto iter = texturesByBitsPtr.find(std::make_tuple(bm->bits, (int)bm->w, (int)bm->h));
if (iter != texturesByBitsPtr.end()) {
return &iter->second;
}
@@ -685,13 +701,27 @@
isDirty = true;
}
} else {
+ // t->bitmap wraps its source pixels rather than owning a copy (see
+ // convert_texture()), and a cache hit means bm->bits is by
+ // definition the same pointer this entry was first cached with
+ // (opengl_get_texture()'s key includes it) - so memmove-ing
+ // bm->bits into t->bitmap->pixels used to be a no-op self-copy,
+ // never actually refreshing anything. bm->bits is a slot in the
+ // resource system's own bounded LRU cache, reused for different
+ // content across draws while its address stays the same, so a hit
+ // here doesn't mean the pixels are still current - only that this
+ // entry's GL texture object and converted-surface buffer are the
+ // right size to reuse. Re-pointing at the current source instead
+ // keeps every other field (dimensions, palette, clip rect) intact
+ // while making the surface actually see this draw's real pixels.
if (bm->type == BMT_RSD8) {
grs_bitmap decoded;
gr_rsd8_convert(bm, &decoded);
- SDL_memmove(t->bitmap->pixels, decoded.bits, bm->w * bm->h);
+ t->bitmap->pixels = decoded.bits;
} else {
- SDL_memmove(t->bitmap->pixels, bm->bits, bm->w * bm->h);
+ t->bitmap->pixels = bm->bits;
}
+ t->bitmap->pitch = bm->row;
setPaletteForBitmap(bm, t->bitmap);
SDL_BlitSurface(t->bitmap, nullptr, t->converted, nullptr);
+94
View File
@@ -0,0 +1,94 @@
# 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. Every card for a resource a mod overrides
# shows the original alongside it for comparison. 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
+424
View File
@@ -0,0 +1,424 @@
# 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
bitmaps/
<label>.json # an AI-upscaled set of critter/object art resources
```
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.
Each `bitmaps/*.json` names a shared upscale configuration and the
resources to apply it to - `gen_animgallery.sh`'s own checkbox-driven MDL
generator (its rendered gallery page has a selection UI that assembles this
exact JSON shape from whatever's checked) produces this format directly:
| Field | Meaning |
|---|---|
| `model` | Which baked-in ONNX model to upscale with - see the table below |
| `scale` | Output size as a multiple of the original (for example `2`) |
| `resources` | Each entry is `"0x<ID>"` (language `Any`) or `"0x<ID>_<Language>"` (for example `"0x25_German"`), matching `gen_animgallery.sh`'s own id format |
| `considerPreviousFrames` | Optional, same id format as `resources` - an id here that isn't also in `resources` is simply never applied, not an error. A listed resource with 3+ frames gets a temporal-consistency pass (section 5) that pulls a frame's upscaled pixel back toward the rest of the animation's own consensus wherever it disagrees by more than the real low-res source justifies - a real mitigation for per-frame upscale flicker. Lets one resource opt in without splitting it into its own file or changing `model`/`scale` for the rest. |
Available `model` values (baked into `mdlp-image`, see
`mdlp-image/upscale_batch.py`):
| `model` | Checkpoint | Best for |
|---|---|---|
| `realesrgan-x4plus-anime` | Real-ESRGAN's 6-block anime/illustration RRDBNet (16.7M params) | Flat-color sprite art - keeps hard edges instead of smoothing them the way a photo/video model would. Default choice. |
| `realesrgan-x4plus` | Real-ESRGAN's general-purpose photo RRDBNet (16.7M params) | A specific resource where the anime model genuinely mis-renders a region (seen on some high-contrast color boundaries) - less edge-sharp overall, much slower, and can mis-render its own different regions, so check the result before keeping it |
| `realesr-general-x4v3` | Real-ESRGAN's compact general-purpose SRVGGNet (1.2M params) | A specific resource neither RRDBNet model renders cleanly - noticeably softer than either, but the most robust against both of their failure modes. Not a blanket replacement, just a last resort for one stubborn sprite |
### 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`.
## 5. Testing
Verifying a mod's built output (particularly an AI-upscaled one, section
4.1) means comparing its rendered result against the unmodified base game,
not just eyeballing the result in isolation - a subtly wrong color or a
corrupted frame can look plausible on its own. `gen_animgallery.sh` renders
straight from a built mod's own resource data (`dist/mods/<name>/mod`), the
same bytes the engine loads, so a problem visible there is a problem in the
mod, not an artifact of some other preview path. Regenerate it with `-data
res/assets/ss_ee/data -data dist/mods/<name>/mod` layered over the base
data to see the mod's effect, or with just the base `-data` argument to
check what the unmodified resource looks like for comparison. Each card
shows the looping GIF plus a static filmstrip of every one of its frames
side by side, so one specific frame can be singled out and compared without
having to pause the animation by eye. When two or more `-data` directories
are layered this way, a card for any resource a later layer actually
overrides shows both versions side by side - the first directory's own
("base") and the winning one ("this build") - so a mod's effect is always
checked against the original it's replacing, not recalled from memory or a
separate base-only gallery run. Cards are sectioned by the `-data`
directory they come from - "base game" first, then one section per mod
(named after `dist/mods/<name>`) - so a mod's own animations, for example
all of `upscale_medbot`'s, are listed together.
`gen_animgallery.sh` is a from-scratch Go decoder, not the game engine -
it never exercises the engine's own texture upload/caching code at all.
A clean gallery render rules out a bug in the mod's resource *data*, not a
bug in how the engine *renders* that data - the two are independent, and a
problem can exist in only one of them. Actually running the built mod
(`../dist/run-mods.sh` or `run.sh` with a single mod folder, section 4.3)
is the only way to check the second. This in-engine verification has so
far only been through the desktop build, not the Quest headset - both
read the same resource data and the engine-side bugs found here
(`engine-patches/`, root [README.md](../README.md) section 4's patch
reference) were resource-data-adjacent, not OpenXR-specific, so a fix at
this level should carry over, but it isn't headset-confirmed until tested
there.
The AI-upscale pipeline (`src/mdlp/upscale.go`) and the engine's own
rendering of its output have a failure mode worth specifically checking
for, found by comparing upscaled output against the base game across many
resources rather than trusting a handful of spot-checks:
- **Wrong-hue speckling in dark or muted regions.** SS1's palette has a
handful of fully-saturated "pure color ramp" entries (pure cyan, red,
green, ...) that sit deceptively close, in color-distance terms, to
common dark/muted shading colors - a plain nearest-color search, with or
without dithering, can pick one of these for a near-black pixel the AI
upscaler produced, since color distance near black barely penalizes an
unrelated hue. `isPureRampColor` excludes these entries from
re-quantization entirely rather than relying on a brightness or
saturation threshold, which proved too easy for a genuinely
dark-but-muted pixel to slip past.
- **Per-frame hallucination in an animation's upscaled output.**
`upscale_batch.py` runs every frame through the ONNX model independently,
with no shared state between frames - a well-known limitation of
single-image super-resolution. On a low-detail/ambiguous source region
the model can guess differently frame to frame, so a detail (an extra
claw, a stray highlight) pops in for a few frames and back out, even
though the real low-resolution source barely changed there. Review by eye
(`make animgallery_mods`/`animgallery_dist`, section 4.3) before trusting
a new upscale definition - this is invisible from a single frame or a
handful of spot-checks and needs to be checked per-animation, ideally
with the GIF slowed down and its base-vs-this-build comparison columns
(both features of the gallery itself) rather than played at real speed.
`considerPreviousFrames` (section 4.1) is a real mitigation, not a
different model/scale gamble, for a resource found to have this problem:
list just the resources that need it and the rest of the definition keeps
its own `model`/`scale` unchanged. For each listed resource with 3+
frames, after independent per-frame upscaling, each frame's pixels are
compared against the per-pixel median across every frame of the same
(cyclic) animation - does the upscaled difference from that consensus
exceed what the real low-res source's own difference from its consensus
justifies - and pulled to the median where it does, on the same model the
rest of the definition already uses. Try it before reaching for a
different model or scale. It needs 3+ frames for a meaningful consensus (a
2-frame animation has no independent third frame to vote with), and never
touches alpha or
an already-transparent pixel.
`encodeFrames` compresses every frame against an all-zero reference
instead of diffing against the previous frame's real pixels, because
that's the only convention the game's actual RSD8 decoder implements - so
an animation's frames never depend on each other, and a size change
between poses isn't a concern to test for. This is confirmed directly
against Night Dive's officially published original Mac source
(`ShockMac/Libraries/2D/Source/RSD/{RSDCVT.C,RSDUnpack.c}`), not just the
vendored Shockolate snapshot in `engine/`: both files are logic-identical
to their vendored counterparts (same 1994 RCS revision comments, same
unconditional `LG_memset(p_dst, kSkipColor, ...)` zero-fill in every skip
branch), and `RSDUnpack.c` additionally carries the original hand-written
68K assembly decoder alongside its later PowerPC/C port - its own skip
handler also zeroes an output register rather than reading from a
reference. Two independently-authored implementations, spanning different
CPU architectures and years apart, agreeing on "skip means zero" down to
matching source comments ("skip (zero) bytes") rules this out as an
overlooked bug rather than deliberate, original design.
The RLE format's own opcode grammar does permit the alternative reading -
"skip" meaning "retain the reference image's value" rather than "write
zero" (`ss-specs` documents this as relevant to compressing "video
clips", which is what led `hacked`'s own GIF-import path, and therefore
earlier versions of `encodeFrames`, to use it). Searching the original
source for anywhere that convention is actually exercised turned up
nothing: SS1's two "video clip" features - cutscenes (`MoviePlay.c`) and
"video mail" (`VMAIL.C`, itself built directly on `MoviePlay.h`) - both
use an entirely separate movie codec, not RSD8, and neither references
the RSD8 decoder at all.
`gen_animgallery.sh` and the real engine therefore always agree on an
upscaled animation's frames; a difference between them points at a
genuine engine rendering bug (root [README.md](../README.md) section 4's
patch reference) rather than this pipeline's own output, and
`engine-debug-tools/`'s debug dump hotkey (root README.md section 4.5
"Debugging") dumps the engine's own decoded frame data independent of its
renderer to tell the two apart.
+60
View File
@@ -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 "$@"
' _ "$@"
+35
View File
@@ -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"
+64
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
1
+95
View File
@@ -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"
+35
View File
@@ -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 "$@"
+46
View File
@@ -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" \
"$@"
+41
View File
@@ -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"
+34
View File
@@ -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"
+94
View File
@@ -0,0 +1,94 @@
# 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 scipy
# The AI upscaler models for mdlp's upscale mod type (see ../README.md
# section 4), selected per mod by a bitmaps/*.json file's own "model" field
# (upscaleDef in ../src/mdlp/upscale.go) - upscale_batch.py resolves a name
# straight to $UPSCALE_MODELS_DIR/<name>.onnx, and picks tiled or single-pass
# inference based on each model's own declared ONNX input shape (fixed vs
# dynamic height/width), so adding another model here needs no code change.
# Each model's RGB output is re-quantized to SS1's fixed 256-color palette
# with Floyd-Steinberg dithering (imageToBitmap in ../src/mdlp/upscale.go),
# not a plain nearest-color map - without it, any model's smooth gradients
# can land on a visibly wrong, much darker palette entry in a solid patch
# rather than unnoticeable dithering noise.
# All three are BSD-3-Clause ONNX exports of xinntao/Real-ESRGAN checkpoints:
# - realesrgan-x4plus-anime: a 6-block RRDBNet trained on flat-color
# illustration, keeping hard sprite edges rather than smoothing them the
# way a photo- or video-denoising model would. Dynamic input shape. Default
# choice - sharpest result on most sprites.
# - realesrgan-x4plus: the general-purpose photo checkpoint (16.7M-param
# RRDBNet). Fixed 128x128 input, needs tiling for anything larger, and
# much slower than the other two. Worth trying when the anime model
# mis-renders a specific sprite, but can itself mis-render different ones.
# - realesr-general-x4v3: a compact 1.2M-param SRVGGNet, dynamic input
# shape, ~100x faster than realesrgan-x4plus. Noticeably softer than
# either RRDBNet model, but the most robust against both models' failure
# modes - the model to reach for on a specific sprite neither RRDBNet
# model renders cleanly, not as a blanket replacement.
ARG UPSCALE_MODEL_ANIME_URL=https://huggingface.co/skillsafe-ai/realesrgan-x4plus-anime-6b/resolve/main/model.onnx
ARG UPSCALE_MODEL_GENERAL_URL=https://huggingface.co/qualcomm/Real-ESRGAN-x4plus/resolve/6f5749d5adff39de0127e42bc45c8ec8e6f76a66/Real-ESRGAN-x4plus.onnx
ARG UPSCALE_MODEL_COMPACT_URL=https://huggingface.co/jonathanst29/tinier-upscale-models/resolve/main/realesr-general-x4v3.onnx
RUN mkdir -p /opt/upscale-models \
&& curl -sSL -o /opt/upscale-models/realesrgan-x4plus-anime.onnx "$UPSCALE_MODEL_ANIME_URL" \
&& curl -sSL -o /opt/upscale-models/realesrgan-x4plus.onnx "$UPSCALE_MODEL_GENERAL_URL" \
&& curl -sSL -o /opt/upscale-models/realesr-general-x4v3.onnx "$UPSCALE_MODEL_COMPACT_URL"
COPY mdlp-image/upscale_batch.py /usr/local/bin/upscale_batch.py
RUN chmod 755 /usr/local/bin/upscale_batch.py
ENV UPSCALE_MODELS_DIR=/opt/upscale-models
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
+1
View File
@@ -0,0 +1 @@
1
+35
View File
@@ -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 "$@"
+49
View File
@@ -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"
+20
View File
@@ -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
+152
View File
@@ -0,0 +1,152 @@
#!/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.
Resolves <model-name> to $UPSCALE_MODELS_DIR/<model-name>.onnx and picks
single-pass or tiled inference from that model's own declared ONNX input
shape - a model with a fixed height/width (e.g. 128x128) needs tiling for
any image bigger than that; one with symbolic dimensions runs the whole
image through in one pass, whatever its size.
Usage: upscale_batch.py <model-name> <in-dir> <out-dir> <scale>
"""
import sys
import os
import numpy as np
import onnxruntime as ort
from PIL import Image
from scipy import ndimage
def extend_opaque_color(rgb, alpha, threshold=128):
"""Replaces every below-threshold-alpha pixel's RGB with its nearest
opaque neighbor's, so the model (RGB-only, see run_tile()) sees a
smooth color field instead of a hard edge against flat black -
otherwise its convolutions blend genuine edge colors with that black
background, leaving dark fringing on the final (correctly re-masked)
output."""
transparent = np.asarray(alpha) < threshold
if not transparent.any() or transparent.all():
return rgb
_, indices = ndimage.distance_transform_edt(transparent, return_indices=True)
arr = np.asarray(rgb)
filled = arr[indices[0], indices[1]]
return Image.fromarray(np.where(transparent[..., None], filled, arr))
def run_tile(session, input_name, tile_rgb):
"""Runs one tile (already at the model's required size) and returns its
upscaled RGB as a uint8 HxWx3 array - handles both the with-batch-dim
([1,3,H,W]) and without ([3,H,W]) output conventions different ONNX
exports use."""
arr = tile_rgb.astype(np.float32) / 255.0
arr = np.transpose(arr, (2, 0, 1))[np.newaxis, ...]
out = session.run(None, {input_name: arr})[0]
if out.ndim == 4:
out = out[0]
out = np.clip(out, 0.0, 1.0) * 255.0
return np.transpose(out, (1, 2, 0)).astype(np.uint8)
def upscale_dynamic(session, input_name, rgb_image):
return Image.fromarray(run_tile(session, input_name, np.asarray(rgb_image)))
def upscale_tiled(session, input_name, rgb_image, tile_size, native_scale):
"""Splits an image larger than the model's fixed input size into
overlapping tile_size x tile_size tiles (edge-padding any tile that
would otherwise run past the image bounds), runs each individually, and
stitches the results back into one canvas - later tiles simply
overwrite the small overlap region of earlier ones, which is invisible
at sprite scale."""
src = np.asarray(rgb_image)
h, w = src.shape[:2]
canvas = np.zeros((h * native_scale, w * native_scale, 3), dtype=np.uint8)
overlap = 16
stride = tile_size - overlap
y = 0
while True:
ty = min(y, max(0, h - tile_size))
x = 0
while True:
tx = min(x, max(0, w - tile_size))
tile = src[ty:ty + tile_size, tx:tx + tile_size]
th, tw = tile.shape[:2]
if th < tile_size or tw < tile_size:
tile = np.pad(tile, ((0, tile_size - th), (0, tile_size - tw), (0, 0)), mode="edge")
out = run_tile(session, input_name, tile)[: th * native_scale, : tw * native_scale]
oy, ox = ty * native_scale, tx * native_scale
canvas[oy:oy + th * native_scale, ox:ox + tw * native_scale] = out
if tx + tile_size >= w:
break
x += stride
if ty + tile_size >= h:
break
y += stride
return Image.fromarray(canvas)
def main():
model_name, in_dir, out_dir, scale_arg = sys.argv[1:5]
scale = int(scale_arg)
models_dir = os.environ["UPSCALE_MODELS_DIR"]
model_path = os.path.join(models_dir, f"{model_name}.onnx")
if not os.path.isfile(model_path):
available = sorted(f[:-5] for f in os.listdir(models_dir) if f.endswith(".onnx"))
sys.exit(f"unknown upscale model {model_name!r} - available: {', '.join(available)}")
session = ort.InferenceSession(model_path, providers=["CPUExecutionProvider"])
input_info = session.get_inputs()[0]
input_name = input_info.name
# A fixed (integer) height/width means this model only ever accepts
# that exact input size - a dynamic one (reported as a symbolic name
# such as "height") accepts any size in one pass. For a fixed-size
# model, its own declared output shape gives the native scale factor
# directly, with no need to run it just to find out.
fixed_size = input_info.shape[2] if isinstance(input_info.shape[2], int) else None
native_scale = session.get_outputs()[0].shape[-1] // fixed_size if fixed_size else None
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]
rgb = extend_opaque_color(rgb, alpha)
# 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.
if fixed_size is not None and (img.width > fixed_size or img.height > fixed_size):
upscaled_rgb = upscale_tiled(session, input_name, rgb, fixed_size, native_scale)
elif fixed_size is not None:
padded = np.pad(np.asarray(rgb), ((0, fixed_size - img.height), (0, fixed_size - img.width), (0, 0)), mode="edge")
out = run_tile(session, input_name, padded)
upscaled_rgb = Image.fromarray(out[: img.height * native_scale, : img.width * native_scale])
else:
upscaled_rgb = upscale_dynamic(session, input_name, 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()
+51
View File
@@ -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" \
"$@"
+40
View File
@@ -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,27 @@
{
"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"
],
"considerPreviousFrames": ["0x7B1"]
}
@@ -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"
]
}
+12
View File
@@ -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
+10
View File
@@ -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=
+889
View File
@@ -0,0 +1,889 @@
// 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
// a static filmstrip of every frame side by side (the loop alone doesn't let
// a specific frame be singled out), into an HTML index page, so they can be
// browsed by eye instead of guessed at by resource ID. Layering a mod's own
// build output over the base game data (repeated -data) renders the result
// as actually played - and for any resource the mod overrides, the base
// game's own version alongside it, for a direct comparison. The index page
// sections its cards by the -data layer they come from: the base game, then
// each mod.
package main
import (
"flag"
"fmt"
"html"
"image"
"image/color"
"image/draw"
"image/gif"
"image/png"
"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
filmstripName string
// group names the winning -data layer (see groupName), dirIndex is that
// layer's position among the -data directories - the index page
// sections its cards by group, in dirIndex order.
group string
dirIndex int
// baseGifName/baseFilmstripName are set only when this resource's
// winning layer isn't the first -data directory - the same content
// rendered from that first (base) directory alone, for a side-by-side
// comparison against what a later layer (a mod) overrides it with.
baseGifName string
baseFilmstripName 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 }
// groupName labels one -data layer for the index page: "base game" for the
// first one, otherwise the mod's name. A built mod lives at
// dist/mods/<name>/mod, so a directory named "mod" is labeled by its parent.
func groupName(absDir string, dirIndex int) string {
if dirIndex == 0 {
return "base game"
}
name := filepath.Base(absDir)
if name == "mod" {
name = filepath.Base(filepath.Dir(absDir))
}
return name
}
// 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)
groupByDirIndex := make(map[int]string)
for dirIndex, dir := range dirs {
absDir, err := filepath.Abs(dir)
if err != nil {
log.Fatalf("resolving -data %q: %v", dir, err)
}
groupByDirIndex[dirIndex] = groupName(absDir, dirIndex)
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)
}
// baseMod holds only the first -data directory, unlayered - resolving a
// resource through it instead of the full mod gives back what a later
// layer (a mod) is actually overriding, for a side-by-side comparison on
// every entry one of those layers changes. Reloaded independently of the
// dirs loop above rather than reusing its ManifestEntry, to keep that
// loop's own bookkeeping (sourceFileByKey, ...) untangled from this.
var baseMod *world.Mod
if len(dirs) > 1 {
baseAbsDir, err := filepath.Abs(dirs[0])
if err != nil {
log.Fatalf("resolving -data %q: %v", dirs[0], err)
}
baseEntry, err := loadDataDir(baseAbsDir)
if err != nil {
log.Printf("base-layer comparison disabled: reloading %s: %v", baseAbsDir, err)
} else {
sort.Slice(baseEntry.Resources, func(i, j int) bool { return baseEntry.Resources[i].ID < baseEntry.Resources[j].ID })
candidate := world.NewMod(func([]resource.ID, []resource.ID) {}, func() {})
if err := candidate.World().InsertEntry(candidate.World().EntryCount(), baseEntry); err != nil {
log.Printf("base-layer comparison disabled: registering %s: %v", baseAbsDir, err)
} else {
baseMod = candidate
}
}
}
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.
entry, ok, err := renderForMod(mod, defaultPal, sourceFile, key.ID, key.Lang, gifDir, "", *minFrames)
if err != nil {
log.Printf("skipping %s (%s): %v", key.ID, sourceFile, err)
continue
}
if !ok {
continue
}
entry.dirIndex = sourceDirIndexByKey[key]
entry.group = groupByDirIndex[entry.dirIndex]
// A later layer wins this key over the first (base) -data directory
// - also render the base directory's own version of it, so the two
// can be shown side by side instead of only the result actually
// in use.
if baseMod != nil && sourceDirIndexByKey[key] != 0 {
baseEntry, ok, err := renderForMod(baseMod, defaultPal, sourceFile, key.ID, key.Lang, gifDir, "_base", *minFrames)
if err != nil {
log.Printf("skipping base comparison for %s (%s): %v", key.ID, sourceFile, err)
} else if ok {
entry.baseGifName = baseEntry.gifName
entry.baseFilmstripName = baseEntry.filmstripName
}
}
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 block's RLE data can carry a "skip" run, which
// bitmap.DecodeReferenced leaves untouched in whatever buffer it's handed -
// the provider function below always hands it a fresh zero buffer, never a
// previous block's pixels, because the game's actual RSD8 decoder
// (engine/src/Libraries/2D/Source/RSD/{rsdcvt.c,RSDUnpack.c}, see
// upscale.go's encodeFrames) treats a skip run as literal zero for every
// resource type including critter sprites, with no mechanism to carry a
// reference image's pixels forward. hacked's own multi-block Bitmap preview
// (editor/bitmaps/View.go) matches that: it always calls Texture() with no
// reference. A non-zero buffer here would ghost a previous block's stale
// pixels into the current block's skip-run regions.
func decodeFrames(view resource.View) ([]*bitmap.Bitmap, error) {
count := view.BlockCount()
frames := make([]*bitmap.Bitmap, 0, count)
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) {
return make([]byte, int(w)*int(h)), nil
})
if err != nil {
return nil, fmt.Errorf("block %d: %w", i, err)
}
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)
}
// gallerySpeedFactor slows every gallery GIF down to a fraction of real
// in-game speed, since the real speed is often too fast to inspect a frame's
// detail by eye (the point of this gallery).
const gallerySpeedFactor = 3
// writeGIF composes every frame onto one shared canvas, sized to the larger
// of minWidth/minHeight (the format's own declared animation extent, 0 if
// unknown) and the largest individual frame, and places each frame's pixels
// at its top-left corner. A frame smaller than the canvas is a storage-level
// crop, not a smaller sprite (System Shock animation frames can be
// RLE-delta-encoded against the previous frame's unchanged pixels, see
// bitmap.DecodeReferenced), so every encoded GIF frame must still cover the
// whole canvas itself - otherwise GIF disposal rules leave the uncovered
// border showing whatever the previous frame drew there, bleeding stale
// content from frame to frame (visible in an external GIF viewer; System
// Shock itself always swaps in a complete texture per frame, so this never
// shows up in-game).
func writeGIF(path string, frames []*bitmap.Bitmap, fallbackPal bitmap.Palette, delays []int, minWidth, minHeight int) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
canvasWidth, canvasHeight := minWidth, minHeight
for _, bmp := range frames {
if int(bmp.Header.Width) > canvasWidth {
canvasWidth = int(bmp.Header.Width)
}
if int(bmp.Header.Height) > canvasHeight {
canvasHeight = 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.
out := gif.GIF{LoopCount: 0, Config: image.Config{Width: canvasWidth, Height: canvasHeight}}
canvasRect := image.Rect(0, 0, canvasWidth, canvasHeight)
for i, bmp := range frames {
colorPal := paletteFor(bmp, fallbackPal)
img := image.NewPaletted(canvasRect, colorPal)
frameWidth, frameHeight := int(bmp.Header.Width), int(bmp.Header.Height)
if len(bmp.Pixels) == frameWidth*frameHeight {
for y := 0; y < frameHeight; y++ {
srcOffset := y * frameWidth
dstOffset := img.PixOffset(0, y)
copy(img.Pix[dstOffset:dstOffset+frameWidth], bmp.Pixels[srcOffset:srcOffset+frameWidth])
}
}
out.Image = append(out.Image, img)
out.Delay = append(out.Delay, delays[i])
}
return gif.EncodeAll(f, &out)
}
// glyph5x7 is a minimal 5-column x 7-row bitmap font, hand-drawn rather than
// pulled from golang.org/x/image/font/basicfont: that package is only a
// test-only transitive dependency of hacked, never actually fetched into
// mdlp-image's offline module cache, so using it here would need a
// Dockerfile change and an image rebuild for a handful of label glyphs.
// Only the characters writeFilmstrip's "FRAME N" labels ever use are
// defined; '#' is a lit pixel, anything else is blank.
var glyph5x7 = map[rune][7]string{
' ': {" ", " ", " ", " ", " ", " ", " "},
'0': {".###.", "#...#", "#...#", "#...#", "#...#", "#...#", ".###."},
'1': {"..#..", ".##..", "..#..", "..#..", "..#..", "..#..", ".###."},
'2': {".###.", "#...#", "....#", "...#.", "..#..", ".#...", "#####"},
'3': {".###.", "#...#", "....#", "..##.", "....#", "#...#", ".###."},
'4': {"...#.", "..##.", ".#.#.", "#..#.", "#####", "...#.", "...#."},
'5': {"#####", "#....", "####.", "....#", "....#", "#...#", ".###."},
'6': {".###.", "#....", "#....", "####.", "#...#", "#...#", ".###."},
'7': {"#####", "....#", "...#.", "..#..", ".#...", ".#...", ".#..."},
'8': {".###.", "#...#", "#...#", ".###.", "#...#", "#...#", ".###."},
'9': {".###.", "#...#", "#...#", ".####", "....#", "....#", ".###."},
'A': {".###.", "#...#", "#...#", "#####", "#...#", "#...#", "#...#"},
'E': {"#####", "#....", "#....", "####.", "#....", "#....", "#####"},
'F': {"#####", "#....", "#....", "####.", "#....", "#....", "#...."},
'M': {"#...#", "##.##", "#.#.#", "#...#", "#...#", "#...#", "#...#"},
'R': {"####.", "#...#", "#...#", "####.", "#.#..", "#..#.", "#...#"},
}
// drawLabel renders s (upper-cased, unknown runes blank) at (x, y) in a
// fixed 5x7 pixel font, one pixel of gap between characters - small and
// legible enough for a "FRAME N" strip label, with no font-rendering
// dependency.
func drawLabel(canvas draw.Image, x, y int, s string, c color.Color) {
col := x
for _, r := range strings.ToUpper(s) {
glyph, ok := glyph5x7[r]
if !ok {
glyph = glyph5x7[' ']
}
for row, line := range glyph {
for bit, ch := range line {
if ch == '#' {
canvas.Set(col+bit, y+row, c)
}
}
}
col += 6 // 5px glyph + 1px gap
}
}
// writeFilmstrip renders every frame side by side at native size, each
// labeled "frame N", separated by a thin gap, as a static complement to the
// looping GIF - the index page otherwise only shows the loop in motion,
// never all of an animation's frames side by side for comparison, or which
// index a given frame is.
func writeFilmstrip(path string, frames []*bitmap.Bitmap, fallbackPal bitmap.Palette) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
const gap = 2
const labelHeight = 14
totalWidth, maxHeight := -gap, 0
for _, bmp := range frames {
totalWidth += int(bmp.Header.Width) + gap
if int(bmp.Header.Height) > maxHeight {
maxHeight = int(bmp.Header.Height)
}
}
canvas := image.NewRGBA(image.Rect(0, 0, totalWidth, labelHeight+maxHeight))
draw.Draw(canvas, canvas.Bounds(), image.NewUniform(color.Black), image.Point{}, draw.Src)
labelColor := color.RGBA{200, 200, 200, 255}
x := 0
for i, bmp := range frames {
drawLabel(canvas, x+1, 2, fmt.Sprintf("FRAME %d", i), labelColor)
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)
}
dstRect := image.Rect(x, labelHeight, x+int(bmp.Header.Width), labelHeight+int(bmp.Header.Height))
draw.Draw(canvas, dstRect, img, image.Point{}, draw.Src)
x += int(bmp.Header.Width) + gap
}
return png.Encode(f, canvas)
}
// 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, suffix 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, suffix)
}
// suffix distinguishes a base-layer comparison render ("_base") from the
// normal, winning-layer one ("") for the same resource ID/language, so the
// two don't collide on the same output filenames in outDir.
func renderMultiBlockBitmap(view resource.View, fallbackPal bitmap.Palette, sourceFile string, id resource.ID, lang resource.Language, outDir, suffix 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 * gallerySpeedFactor // no explicit timing exists for this raw form
}
gifName := fmt.Sprintf("%04X_%s%s.gif", int(id), lang.String(), suffix)
// No separate declared canvas size exists for this content type (unlike
// Animation descriptors below) - writeGIF falls back to the largest
// individual frame.
if err := writeGIF(filepath.Join(outDir, gifName), frames, fallbackPal, delays, 0, 0); err != nil {
return galleryEntry{}, err
}
filmstripName := fmt.Sprintf("%04X_%s%s_frames.png", int(id), lang.String(), suffix)
if err := writeFilmstrip(filepath.Join(outDir, filmstripName), frames, fallbackPal); 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,
filmstripName: filmstripName,
}, 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, suffix 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, suffix)
}
// suffix distinguishes a base-layer comparison render ("_base") from the
// normal, winning-layer one ("") - see renderMultiBlockBitmap.
func renderAnimationDescriptor(mod *world.Mod, fallbackPal bitmap.Palette, sourceFile string, id resource.ID, lang resource.Language, outDir, suffix 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
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)
}
// Always a fresh zero buffer, never a previous frame's real
// pixels - see decodeFrames' own comment for why that's what
// matches the game's actual RSD8 decoder (and hacked's own
// bitmap preview) rather than ghosting stale content into a
// skip run.
bmp, err := bitmap.DecodeReferenced(reader, func(w, h int16) ([]byte, error) {
return make([]byte, int(w)*int(h)), nil
})
if err != nil {
return galleryEntry{}, fmt.Errorf("frame %d: %w", frameIndex, err)
}
frames = append(frames, bmp)
delay := int(entry.FrameTime) / 10 * gallerySpeedFactor
if delay <= 0 {
delay = 10 * gallerySpeedFactor
}
delays = append(delays, delay)
frameIndex++
}
}
if len(frames) == 0 {
return galleryEntry{}, fmt.Errorf("no frames decoded")
}
gifName := fmt.Sprintf("%04X_%s%s.gif", int(id), lang.String(), suffix)
// anim.Width/Height is this format's own declared canvas, which can be
// larger than any single frame's stored (possibly delta-cropped) size.
if err := writeGIF(filepath.Join(outDir, gifName), frames, fallbackPal, delays, int(anim.Width), int(anim.Height)); err != nil {
return galleryEntry{}, err
}
filmstripName := fmt.Sprintf("%04X_%s%s_frames.png", int(id), lang.String(), suffix)
if err := writeFilmstrip(filepath.Join(outDir, filmstripName), frames, fallbackPal); 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,
filmstripName: filmstripName,
}, nil
}
// renderForMod resolves id/lang through m (a specific layering - the full
// mod, or just a base game directory on its own) and renders it, the same
// content-type dispatch the main scan uses, so it can also be called a
// second time against a base-only Mod to render a comparison. ok is false
// (with a nil error) when id/lang isn't an eligible animated resource in m
// at all, as opposed to being eligible but failing to decode or render.
func renderForMod(m *world.Mod, fallbackPal bitmap.Palette, sourceFile string, id resource.ID, lang resource.Language, outDir, suffix string, minFrames int) (entry galleryEntry, ok bool, err error) {
view, err := m.LocalizedResources(lang).Select(id)
if err != nil {
return galleryEntry{}, false, nil
}
switch {
case view.ContentType() == resource.Animation:
entry, err = safeRenderAnimationDescriptor(m, fallbackPal, sourceFile, id, lang, outDir, suffix)
return entry, true, err
case view.ContentType() == resource.Bitmap && view.BlockCount() >= minFrames:
entry, err = safeRenderMultiBlockBitmap(view, fallbackPal, sourceFile, id, lang, outDir, suffix)
return entry, true, err
}
return galleryEntry{}, false, 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(".card img.frames{margin-top:4px;max-width:100%}")
b.WriteString(".compare{display:flex;gap:10px}")
b.WriteString(".compare-label{font-size:11px;color:#8ab;text-transform:uppercase;margin-bottom:2px}")
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>")
// One quick-filter button per group, in -data order.
groupNames := make(map[int]string)
for _, e := range entries {
groupNames[e.dirIndex] = e.group
}
groupIndexes := make([]int, 0, len(groupNames))
for i := range groupNames {
groupIndexes = append(groupIndexes, i)
}
sort.Ints(groupIndexes)
groupButtons := `<br><span id="group-filters">Groups:`
for _, i := range groupIndexes {
name := html.EscapeString(groupNames[i])
groupButtons += fmt.Sprintf(` <button type="button" class="group-filter" data-filter="%s">%s</button>`, name, name)
}
groupButtons += `</span>`
b.WriteString(`<div id="controls">` +
`<label>Model: <select id="model">` +
`<option>realesrgan-x4plus-anime</option>` +
`<option>realesrgan-x4plus</option>` +
`<option>realesr-general-x4v3</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>` +
`<button id="clear-filter" type="button">Clear filter</button>` +
`<span id="shown-count"></span>` +
groupButtons +
`<textarea id="mdl-output" rows="12" readonly placeholder="Select animations below to generate an MDL upscale definition here - copy it into modding/mods/&lt;name&gt;/bitmaps/&lt;label&gt;.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))
// Cards are sectioned per -data layer (the base game, then each mod), and
// within one by content type: animation descriptors and multi-block
// bitmaps are genuinely distinct (different decode path, see
// contentTypeName).
type section struct {
group string
dirIndex int
animations, bitmaps []galleryEntry
}
var sections []*section
sectionByDir := make(map[int]*section)
for _, e := range entries {
sec := sectionByDir[e.dirIndex]
if sec == nil {
sec = &section{group: e.group, dirIndex: e.dirIndex}
sectionByDir[e.dirIndex] = sec
sections = append(sections, sec)
}
if contentTypeName(e.kind) == "Animation" {
sec.animations = append(sec.animations, e)
} else {
sec.bitmaps = append(sec.bitmaps, e)
}
}
sort.Slice(sections, func(i, j int) bool { return sections[i].dirIndex < sections[j].dirIndex })
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, e.group,
fmt.Sprintf("%dx%d", e.width, e.height), transparency,
}, " "))
fmt.Fprintf(&b, "<div class=\"card\" data-search=\"%s\">", html.EscapeString(search))
if e.baseGifName != "" {
b.WriteString("<div class=\"compare\">")
b.WriteString("<div class=\"compare-col\"><div class=\"compare-label\">base</div>")
fmt.Fprintf(&b, "<img src=\"gifs/%s\" width=\"%d\" height=\"%d\">", html.EscapeString(e.baseGifName), e.width*2, e.height*2)
fmt.Fprintf(&b, "<img class=\"frames\" src=\"gifs/%s\" title=\"every base frame, in order\">", html.EscapeString(e.baseFilmstripName))
b.WriteString("</div>")
b.WriteString("<div class=\"compare-col\"><div class=\"compare-label\">this build</div>")
fmt.Fprintf(&b, "<img src=\"gifs/%s\" width=\"%d\" height=\"%d\">", html.EscapeString(e.gifName), e.width*2, e.height*2)
fmt.Fprintf(&b, "<img class=\"frames\" src=\"gifs/%s\" title=\"every frame, in order\">", html.EscapeString(e.filmstripName))
b.WriteString("</div></div>")
} else {
fmt.Fprintf(&b, "<img src=\"gifs/%s\" width=\"%d\" height=\"%d\">", html.EscapeString(e.gifName), e.width*2, e.height*2)
if e.filmstripName != "" {
fmt.Fprintf(&b, "<img class=\"frames\" src=\"gifs/%s\" title=\"every frame, in order\">", html.EscapeString(e.filmstripName))
}
}
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) &middot; %s</label><br>%dx%d, %d frame(s), %s &middot; %s &middot; %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>")
}
}
for _, sec := range sections {
b.WriteString("<div class=\"group\">")
fmt.Fprintf(&b, "<h2>%s (%d)</h2>", html.EscapeString(sec.group), len(sec.animations)+len(sec.bitmaps))
if len(sec.animations) > 0 {
fmt.Fprintf(&b, "<h3>Animations (%d)</h3>", len(sec.animations))
writeCards(sec.animations, fmt.Sprintf("%da", sec.dirIndex))
}
if len(sec.bitmaps) > 0 {
fmt.Fprintf(&b, "<h3>Bitmaps (%d)</h3>", len(sec.bitmaps))
writeCards(sec.bitmaps, fmt.Sprintf("%db", sec.dirIndex))
}
b.WriteString("</div>")
}
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 (its group's 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++;
});
// A group with no visible card left hides its headings too.
Array.prototype.forEach.call(document.querySelectorAll('.group'), function (group) {
var visible = Array.prototype.some.call(group.querySelectorAll('.card'), function (card) {
return card.style.display !== 'none';
});
group.style.display = visible ? '' : 'none';
});
document.getElementById('shown-count').textContent = shown + ' / ' + cards.length + ' shown';
}
document.getElementById('search').addEventListener('input', applyFilter);
function setFilter(text) {
document.getElementById('search').value = text;
applyFilter();
}
document.getElementById('clear-filter').addEventListener('click', function () { setFilter(''); });
Array.prototype.forEach.call(document.querySelectorAll('.group-filter'), function (btn) {
btn.addEventListener('click', function () { setFilter(btn.dataset.filter); });
});
applyFilter();
</script>`)
b.WriteString("</body></html>")
return os.WriteFile(path, []byte(b.String()), 0o644)
}
@@ -0,0 +1,13 @@
--- a/go.mod
+++ b/go.mod
@@ -2,6 +2,10 @@
go 1.25.0
+// Local, patched copy fixing a GTK3/GLFW X11 crash on Linux - see
+// ../sqweek-dialog-patches/ and README.md.
+replace github.com/sqweek/dialog => ../sqweek-dialog
+
require (
github.com/asticode/go-astisub v0.12.1
github.com/go-gl/gl v0.0.0-20210315015930-ae072cafe09d
@@ -0,0 +1,36 @@
--- a/ss1/resource/ChangeNotifier.go
+++ b/ss1/resource/ChangeNotifier.go
@@ -50,24 +50,26 @@
func (notifier ChangeNotifier) hashAll(ids []ID) (hashes resourceHashSnapshot, failedIDs []ID) {
var failedMap IDMarkerMap
hashes = make(resourceHashSnapshot)
- var selectMutex sync.Mutex
+ // Guards the whole of hashResource, not just Select: resources from the
+ // same underlying file share a stateful reader (e.g. a *io.SectionReader
+ // tracking a read offset), so the actual block reads inside
+ // hashResource race just as much as selection would - locking only
+ // Select still let concurrent goroutines corrupt each other's reads.
+ var ioMutex sync.Mutex
var mapMutex sync.Mutex
for _, lang := range Languages() {
hashByResourceID := make(resourceHashes)
hashes[lang] = hashByResourceID
selector := notifier.Localizer.LocalizedResources(lang)
- selectorFunc := func(id ID) (View, error) {
- selectMutex.Lock()
- defer selectMutex.Unlock()
- return selector.Select(id)
- }
var wg sync.WaitGroup
for _, id := range ids {
wg.Add(1)
go func(id ID) {
defer wg.Done()
- hash, hashErr := notifier.hashResource(selectorFunc, id)
+ ioMutex.Lock()
+ hash, hashErr := notifier.hashResource(selector.Select, id)
+ ioMutex.Unlock()
mapMutex.Lock()
defer mapMutex.Unlock()
if hashErr == nil {
+3
View File
@@ -0,0 +1,3 @@
* text=auto eol=lf
*.{cmd,[cC][mM][dD]} text eol=crlf
*.{bat,[bB][aA][tT]} text eol=crlf
+5
View File
@@ -0,0 +1,5 @@
.idea/
_build/
imgui.ini
*.syso
+84
View File
@@ -0,0 +1,84 @@
run:
deadline: 10m
linters:
enable:
- asciicheck
- bodyclose
- deadcode
- depguard
- dogsled
- dupl
- durationcheck
- errcheck
- errorlint
- exportloopref
- forbidigo
- goconst
- gocritic
- godot
- godox
- goerr113
- gofmt
- goheader
- gomoddirectives
- gomodguard
- goprintffuncname
- gosimple
- govet
- importas
- ineffassign
- lll
- makezero
- misspell
- nilerr
- noctx
- nolintlint
- predeclared
- promlinter
- revive
- rowserrcheck
- sqlclosecheck
- staticcheck
- structcheck
- tagliatelle
- testpackage
- thelper
- tparallel
- typecheck
- unconvert
- unused
- varcheck
- wastedassign
- whitespace
# linters are disabled if their majority of issues is considered false-positive (intended code)
# and the remaining issues (if existing) aren't worth it.
disable:
- gochecknoglobals
- gochecknoinits
- goimports # disabled because of so many false-positives with "imgui-go"
- gosec
- maligned
- nakedret
- prealloc
- unparam
- wsl
- funlen
- gocyclo # disabled for UI code
- nestif # disabled for UI code
- gocognit
- stylecheck # enable again for doing all the package documentation.
- gomnd # too many false positives with constants themselves.
issues:
exclude:
# too often for OpenGL constants
- don't use ALL_CAPS in Go names
exclude-use-default: false # disable filtering of defaults for better zero-issue policy
max-per-linter: 0 # disable limit; report all issues of a linter
max-same-issues: 0 # disable limit; report all issues of the same issue
linters-settings:
lll:
line-length: 160
+38
View File
@@ -0,0 +1,38 @@
## Contributing Guidelines
Thank you for considering to contribute to this application!
The following text lists guidelines for contributions.
These guidelines don't have legal status, so use them as a reference and common sense - and feel free to update them as well!
### "I just want to know..."
For questions, or general usage-information about HackEd, please refer to the [systemshock.org](https://systemshock.org) forums (`Engineering` subforum for modding in general, `Deck 13` for source development, including engine code), or refer to the [Wiki](https://github.com/inkyblackness/hacked/wiki).
You might also find information on the data files in the [ss-specs](https://github.com/inkyblackness/ss-specs) project.
### Scope
This is an editor for mods and fan-missions of the video game System Shock (1994, and compatible), written in Go.
If there are extensions to the data files, please be sure that these were discussed and/or documented in `ss-specs`. Usability features are always welcome, I only ask to give them a second thought if there might be a more generic solution (i.e., don't immediately jump to the implementation of "I need X", rather think how a slightly different feature might both give you X and also Y in even a simpler form...)
Please also consider engine compatibility. If there are engine features not supported by all (especially the classic version), please make sure these are indicated to the user. Also refer to the documentation on the Wiki for such cases.
### Code Style
Please make sure code is formatted according to `go fmt`, and use the following linter: [golangci-lint](https://github.com/golangci/golangci-lint).
> If there are linter errors that you didn't introduce, you don't have to clean them up - I might have missed them and will be handling them separately.
### Testing Style
The code under `ss1` should be well tested, please try to keep up coverage. The code under `editor` is tricky to test - if you figure out how to test ImGui based stuff, I'd be happy for a pull request! At least utilities should have tests.
> I am aiming for a coverage of over 90% in there.
For testing use either the vanilla `testing` package, or augment it with the `github.com/stretchr/testify` framework. See existing examples.
+27
View File
@@ -0,0 +1,27 @@
New BSD License
© 2018, Christian Haas
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
* Neither the name of "InkyBlackness" nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE LISTED COPYRIGHT HOLDERS BE LIABLE FOR ANY
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+28
View File
@@ -0,0 +1,28 @@
# InkyBlackness HackEd
HackEd is the main graphical editor application as part of the [InkyBlackness](https://inkyblackness.github.io) project, written in [Go](http://golang.org/). It allows editing of game files of the original System Shock (1994 and compatible) to create mods and fan-missions.
This application is the successor of the `deck` package of InkyBlackness, which provided the first public editor, even before the source release of the original engine. The rewrite was necessary due to several factors.
The [releases page](https://github.com/inkyblackness/hacked/releases) provides the latest binaries for both MS Windows and Linux.
## Documentation
Usage of the editor is documented on its [Wiki](https://github.com/inkyblackness/hacked/wiki) page.
Answers and further help about modding can be furthermore found on the [systemshock.org](https://systemshock.org) forums, particularly the `Engineering` subforum.
## Screenshots
Level editing details:
![v1.0.0 Level Editing](https://user-images.githubusercontent.com/1508944/43354141-b8c536b0-9246-11e8-8014-c306be4ab1bb.png)
Media editing details:
![v1.0.0 Media Editing](https://user-images.githubusercontent.com/1508944/43354142-c3035742-9246-11e8-86d8-31cf62b7ca1b.png)
4K glory showing all the windows:
![v1.0.0 4K Glory](https://user-images.githubusercontent.com/1508944/43354138-a4676cc4-9246-11e8-883f-46ddde4aba25.png)
## License
The project is available under the terms of the **New BSD License** (see LICENSE file).
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<assemblyIdentity
type="win32"
name="InkyBlackness.hacked"
version="§MAJOR.§MINOR.§PATCH.0"
/>
<dependency>
<dependentAssembly>
<assemblyIdentity
type="win32"
name="Microsoft.Windows.Common-Controls"
version="6.0.0.0"
processorArchitecture="*"
publicKeyToken="6595b64144ccf1df"
language="*"
/>
</dependentAssembly>
</dependency>
</assembly>
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

@@ -0,0 +1,3 @@
100 ICON "hacked.ico"
100 24 "hacked.exe.manifest"
GLFW_ICON ICON "hacked.ico"
+17
View File
@@ -0,0 +1,17 @@
# InkyBlackness - HackEd
This package contains the public distribution of the [InkyBlackness](https://inkyblackness.github.io) HackEd binary.
The executable is a modding tool for the DOS game [System Shock (1994)](http://en.wikipedia.org/wiki/System_Shock) and compatibles.
## Disclaimer
A general warning: Keep backups of your files!
This is an executables downloaded from the internet. If you are unsure what they do or how they work, refer to the sources [on GitHub](https://github.com/inkyblackness).
## Requirements
HackEd is a GUI editor for the resources, including a map editor.
The tool uses OpenGL 3.2 .
User documentation is available online here: https://github.com/inkyblackness/hacked/wiki
+76
View File
@@ -0,0 +1,76 @@
#!/bin/bash
pushd $(dirname "${0}") >/dev/null
HACKED_BASE=$(pwd -L)
FOLDER_NAME=inkyblackness-hacked
echo "Cleaning output directories..."
rm -rf _build
rm main-res.syso
mkdir -p $HACKED_BASE/_build/linux/$FOLDER_NAME
mkdir -p $HACKED_BASE/_build/win/$FOLDER_NAME
echo "Determining version"
MAJOR=$(date +%Y)
MINOR=$(date +%m)
PATCH=$(date +%d)
VERSION=$(git describe --tags)
if [ $? -ne 0 ]; then
echo "Could not determine tag, defaulting to revision for version"
REV=$(git rev-parse --short HEAD)
VERSION="rev$REV"
else
VERSION_RAW=$(echo "$VERSION" | cut -d'-' -f 1 | cut -d'v' -f 2)
fi
EXTRA=$(echo "$VERSION" | cut -d'-' -f 2)
if [[ "$VERSION" == "$EXTRA" ]]; then
MAJOR=$(echo "$VERSION_RAW" | cut -d'.' -f 1)
MINOR=$(echo "$VERSION_RAW" | cut -d'.' -f 2)
PATCH=$(echo "$VERSION_RAW" | cut -d'.' -f 3)
fi
echo "Determined version: $VERSION ($MAJOR.$MINOR.$PATCH)"
echo "Preparing build resources"
mkdir -p $HACKED_BASE/_build/win_temp
cp $HACKED_BASE/_resources/build/win/* $HACKED_BASE/_build/win_temp
sed -i "s/§MAJOR/$MAJOR/g" $HACKED_BASE/_build/win_temp/hacked.exe.manifest
sed -i "s/§MINOR/$MINOR/g" $HACKED_BASE/_build/win_temp/hacked.exe.manifest
sed -i "s/§PATCH/$PATCH/g" $HACKED_BASE/_build/win_temp/hacked.exe.manifest
x86_64-w64-mingw32-windres -o main-res.syso $HACKED_BASE/_build/win_temp/hacked.rc
echo "Building executables..."
go build -gcflags "-d=checkptr=0" -ldflags "-X main.version=$VERSION" -a -o $HACKED_BASE/_build/linux/$FOLDER_NAME/hacked -trimpath $(pwd) .
GOOS=windows GOARCH=amd64 CGO_ENABLED=1 CXX=x86_64-w64-mingw32-g++ CC=x86_64-w64-mingw32-gcc go build -gcflags "-d=checkptr=0" -ldflags "-X main.version=$VERSION -H=windowsgui" -a -o $HACKED_BASE/_build/win/$FOLDER_NAME/hacked.exe -trimpath $(pwd) .
echo "Copying distribution resources..."
for os in "linux" "win"; do
packageDir=$HACKED_BASE/_build/$os/$FOLDER_NAME
cp $HACKED_BASE/LICENSE $packageDir
cp -R $HACKED_BASE/_resources/dist/* $packageDir
done
for lib in "libgcc_s_seh-1.dll" "libstdc++-6.dll" "libwinpthread-1.dll"; do
for base in "/usr/x86_64-w64-mingw32/bin" "/usr/lib/gcc/x86_64-w64-mingw32/10-win32" "/usr/x86_64-w64-mingw32/lib"; do
if [ -e $base/$lib ]; then
cp $base/$lib $HACKED_BASE/_build/win/$FOLDER_NAME
fi
done
if [ ! -f $HACKED_BASE/_build/win/$FOLDER_NAME/$lib ]; then
echo " FAIL: file $lib is nowhere found"
fi
done
echo "Creating packages..."
cd $HACKED_BASE/_build/linux
tar -cvzf $HACKED_BASE/_build/$FOLDER_NAME-$VERSION.linux64.tgz ./$FOLDER_NAME
cd $HACKED_BASE/_build/win
zip -r $HACKED_BASE/_build/$FOLDER_NAME-$VERSION.win64.zip .
popd >/dev/null
+4
View File
@@ -0,0 +1,4 @@
package crash
// Handler is a function that tries to preserve some panic/stack info to the user.
var Handler = func(versionInfo string) {}
@@ -0,0 +1,42 @@
package crash
//#include <windows.h>
import "C"
import (
"fmt"
"runtime/debug"
"unsafe"
)
func init() {
Handler = windowsHandler
}
func windowsHandler(versionInfo string) {
if e := recover(); e != nil {
text := `There was a crash in the application. This is bad and I am sorry.
Perhaps you can make something with the details of the error below.
If you can reproduce this, please make a screenshot of this box and report it with details on the http://www.systemshock.org forums.
Thank you!
`
text += fmt.Sprintf("%s:\n%s", e, debug.Stack())
messageBox(text, "Something unexpected happened - "+versionInfo)
}
}
func messageBox(text, caption string) {
textArg, textFin := wrapString(text)
defer textFin()
captionArg, captionFin := wrapString(caption)
defer captionFin()
var hwnd unsafe.Pointer
C.MessageBoxExA((*C.struct_HWND__)(hwnd), textArg, captionArg, C.UINT(0), C.WORD(0))
}
func wrapString(value string) (wrapped *C.char, finisher func()) {
wrapped = C.CString(value)
finisher = func() { C.free(unsafe.Pointer(wrapped)) } // nolint: gas
return
}
+945
View File
@@ -0,0 +1,945 @@
package editor
import (
"encoding/json"
"fmt"
"io/ioutil"
"path/filepath"
"strings"
"github.com/inkyblackness/imgui-go/v3"
"github.com/inkyblackness/hacked/editor/about"
"github.com/inkyblackness/hacked/editor/animations"
"github.com/inkyblackness/hacked/editor/archives"
"github.com/inkyblackness/hacked/editor/bitmaps"
"github.com/inkyblackness/hacked/editor/external"
"github.com/inkyblackness/hacked/editor/graphics"
"github.com/inkyblackness/hacked/editor/levels"
"github.com/inkyblackness/hacked/editor/messages"
"github.com/inkyblackness/hacked/editor/movies"
"github.com/inkyblackness/hacked/editor/objects"
"github.com/inkyblackness/hacked/editor/project"
"github.com/inkyblackness/hacked/editor/render"
"github.com/inkyblackness/hacked/editor/sounds"
"github.com/inkyblackness/hacked/editor/texts"
"github.com/inkyblackness/hacked/editor/textures"
"github.com/inkyblackness/hacked/ss1"
"github.com/inkyblackness/hacked/ss1/content/archive/level"
"github.com/inkyblackness/hacked/ss1/content/bitmap"
"github.com/inkyblackness/hacked/ss1/content/movie"
"github.com/inkyblackness/hacked/ss1/content/sound"
"github.com/inkyblackness/hacked/ss1/content/text"
"github.com/inkyblackness/hacked/ss1/edit"
"github.com/inkyblackness/hacked/ss1/edit/media"
"github.com/inkyblackness/hacked/ss1/edit/undoable"
"github.com/inkyblackness/hacked/ss1/edit/undoable/cmd"
"github.com/inkyblackness/hacked/ss1/resource"
"github.com/inkyblackness/hacked/ss1/world"
"github.com/inkyblackness/hacked/ss1/world/ids"
"github.com/inkyblackness/hacked/ui/gui"
"github.com/inkyblackness/hacked/ui/input"
"github.com/inkyblackness/hacked/ui/opengl"
)
const (
errCouldNotOpenFile ss1.StringError = "could not open file"
errCouldNotReadFile ss1.StringError = "could not read file"
)
type projectState struct {
ProjectSettings *edit.ProjectSettings `json:",omitempty"`
GameStateSettings *edit.GameStateSettings `json:",omitempty"`
OpenWindows []string `json:",omitempty"`
ActiveLevelIndex *int `json:",omitempty"`
}
func projectStateFromFile(filename string) (projectState, error) {
data, err := ioutil.ReadFile(filename)
if err != nil {
return projectState{}, errCouldNotOpenFile
}
var state projectState
err = json.Unmarshal(data, &state)
if err != nil {
return projectState{}, errCouldNotReadFile
}
return state, nil
}
// SaveTo stores the state in a file with given filename.
func (state projectState) SaveTo(filename string) error {
bytes, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
}
return ioutil.WriteFile(filename, bytes, 0640)
}
type lastProjectState struct {
projectState
LastProject string `json:",omitempty"`
}
func lastProjectStateFromFile(filename string) lastProjectState {
data, err := ioutil.ReadFile(filename)
if err != nil {
return lastProjectState{}
}
var state lastProjectState
err = json.Unmarshal(data, &state)
if err != nil {
return lastProjectState{}
}
return state
}
// SaveTo stores the state in a file with given filename.
func (state lastProjectState) SaveTo(filename string) error {
bytes, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
}
return ioutil.WriteFile(filename, bytes, 0640)
}
// Application is the root object of the graphical editor.
// It is set up by the main method.
type Application struct {
// Version identifies the build of the application.
Version string
// ConfigDir is the base path to store configuration in.
ConfigDir string
window opengl.Window
clipboard clipboardAdapter
gl opengl.OpenGL
// FontFile specifies the font to use.
FontFile string
// FontSize specifies the font size to use.
FontSize float32
// GuiScale is applied when the window is initialized.
GuiScale float32
guiContext *gui.Context
lastModifier input.Modifier
lastMouseX float32
lastMouseY float32
txnBuilder cmd.TransactionBuilder
cmdStack *cmd.Stack
mod *world.Mod
cp text.Codepage
textLineCache *text.Cache
textPageCache *text.Cache
messagesCache *text.ElectronicMessageCache
paletteCache *graphics.PaletteCache
textureCache *graphics.TextureCache
frameCache *graphics.FrameCache
animationCache *bitmap.AnimationCache
movieCache *movie.Cache
soundEffectCache *sound.EffectCache
mapDisplay *levels.MapDisplay
levels *edit.EditableLevels
levelSelection *edit.LevelSelectionService
levelEditorService *edit.LevelEditorService
projectService *edit.ProjectService
gameStateService *edit.GameStateService
gameObjectsService *edit.GameObjectsService
projectView *project.View
archiveView *archives.View
levelControlView *levels.ControlView
levelTilesView *levels.TilesView
levelObjectsView *levels.ObjectsView
messagesView *messages.View
textsView *texts.View
bitmapsView *bitmaps.View
texturesView *textures.View
animationsView *animations.View
moviesView *movies.View
soundEffectsView *sounds.View
objectsView *objects.View
aboutView *about.View
licensesView *about.LicensesView
modalState gui.ModalStateWrapper
failureMessage string
failurePending bool
}
// InitializeWindow takes the given window and attaches the callbacks.
func (app *Application) InitializeWindow(window opengl.Window) (err error) {
app.window = window
app.clipboard.window = window
app.gl = window.OpenGL()
app.window.RestoreState(opengl.WindowStateFromFile(app.windowStateConfigFilename(), app.window.StateSnapshot()))
app.initSignalling()
app.initWindowCallbacks()
app.initOpenGL()
app.initModel()
err = app.initGui()
if err != nil {
return
}
app.initView()
app.restoreWorkspace()
app.onWindowResize(app.window.Size())
return
}
func (app *Application) onWindowClosed() {
if app.guiContext != nil {
app.guiContext.Destroy()
app.guiContext = nil
}
}
func (app *Application) initWindowCallbacks() {
app.window.OnClosing(app.onWindowClosing)
app.window.OnClosed(app.onWindowClosed)
app.window.OnFileDropCallback(app.onFilesDropped)
app.window.OnResize(app.onWindowResize)
app.window.OnKeyPress(app.onKeyPress)
app.window.OnKeyRelease(app.onKeyRelease)
app.window.OnCharCallback(app.onChar)
app.window.OnModifier(app.onModifier)
app.window.OnMouseMove(app.onMouseMove)
app.window.OnMouseScroll(app.onMouseScroll)
app.window.OnMouseButtonDown(app.onMouseButtonDown)
app.window.OnMouseButtonUp(app.onMouseButtonUp)
app.window.OnRender(app.render)
}
func (app *Application) render() {
app.guiContext.NewFrame()
width, height := app.window.Size()
app.gl.Viewport(0, 0, int32(width), int32(height))
app.gl.ClearColor(0.0, 0.0, 0.0, 1.0)
app.gl.Clear(opengl.COLOR_BUFFER_BIT)
paletteTexture, _ := app.paletteCache.Palette(0)
app.mapDisplay.Render(
paletteTexture, app.textureCache.Texture,
app.levelTilesView.TextureDisplay(), app.levelTilesView.ColorDisplay())
app.handleFailure()
app.renderMainMenu()
app.projectView.Render()
app.updateAutoSave()
app.archiveView.Render()
app.levelControlView.Render()
app.levelTilesView.Render()
app.levelObjectsView.Render()
app.messagesView.Render()
app.textsView.Render()
app.bitmapsView.Render()
app.texturesView.Render()
app.animationsView.Render()
app.moviesView.Render()
app.soundEffectsView.Render()
app.objectsView.Render()
app.aboutView.Render()
app.licensesView.Render()
app.modalState.Render()
app.guiContext.Render(app.bitmapTextureForUI)
}
func (app *Application) initOpenGL() {
app.gl.Disable(opengl.DEPTH_TEST)
app.gl.Enable(opengl.BLEND)
app.gl.BlendFunc(opengl.SRC_ALPHA, opengl.ONE_MINUS_SRC_ALPHA)
}
func (app *Application) initGui() (err error) {
app.initGuiSizes()
param := gui.ContextParameters{
ConfigDir: app.ConfigDir,
FontFile: app.FontFile,
FontSize: app.FontSize,
}
app.guiContext, err = gui.NewContext(app.window, param)
if err != nil {
return
}
imguiIO := imgui.CurrentIO()
imguiIO.KeyMap(imgui.KeyTab, int(input.KeyTab))
imguiIO.KeyMap(imgui.KeyLeftArrow, int(input.KeyLeft))
imguiIO.KeyMap(imgui.KeyRightArrow, int(input.KeyRight))
imguiIO.KeyMap(imgui.KeyUpArrow, int(input.KeyUp))
imguiIO.KeyMap(imgui.KeyDownArrow, int(input.KeyDown))
imguiIO.KeyMap(imgui.KeyPageUp, int(input.KeyPageUp))
imguiIO.KeyMap(imgui.KeyPageDown, int(input.KeyPageDown))
imguiIO.KeyMap(imgui.KeyHome, int(input.KeyHome))
imguiIO.KeyMap(imgui.KeyEnd, int(input.KeyEnd))
imguiIO.KeyMap(imgui.KeyInsert, int(input.KeyInsert))
imguiIO.KeyMap(imgui.KeyDelete, int(input.KeyDelete))
imguiIO.KeyMap(imgui.KeyBackspace, int(input.KeyBackspace))
imguiIO.KeyMap(imgui.KeyEnter, int(input.KeyEnter))
imguiIO.KeyMap(imgui.KeyEscape, int(input.KeyEscape))
imguiIO.KeyMap(imgui.KeyA, int(input.KeyA))
imguiIO.KeyMap(imgui.KeyC, int(input.KeyC))
imguiIO.KeyMap(imgui.KeyV, int(input.KeyV))
imguiIO.KeyMap(imgui.KeyX, int(input.KeyX))
app.initGuiStyle()
app.mapDisplay = levels.NewMapDisplay(app.gameObjectsService, app.levelSelection, app.levelEditorService,
app.gl, app.GuiScale,
app.gameTexture)
return
}
func (app *Application) gameTexture(index level.TextureIndex) (*graphics.BitmapTexture, error) {
key := resource.KeyOf(ids.LargeTextures.Plus(int(index)), resource.LangAny, 0)
return app.textureCache.Texture(key)
}
func (app *Application) bitmapTextureForUI(textureID imgui.TextureID) (palette uint32, texture uint32) {
idType := byte(textureID >> 48)
switch idType {
case render.BitmapTextureTypeResource:
paletteTexture, _ := app.paletteCache.Palette(0)
if paletteTexture == nil {
return 0, 0
}
lang := resource.Language((textureID >> 32) & 0xFF)
resourceID := resource.ID((textureID >> 16) & 0xFFFF)
blockIndex := int(textureID & 0xFFFF)
key := resource.KeyOf(resourceID, lang, blockIndex)
tex, err := app.textureCache.Texture(key)
if err != nil {
return 0, 0
}
return paletteTexture.Handle(), tex.Handle()
case render.BitmapTextureTypeFrame:
key := graphics.FrameCacheKey(textureID & 0xFFFF)
return app.frameCache.HandlesForKey(key)
}
return 0, 0
}
func (app *Application) onWindowClosing() {
windowState := app.window.StateSnapshot()
_ = windowState.SaveTo(app.windowStateConfigFilename())
app.saveWorkspace()
}
func (app *Application) onWindowResize(width int, height int) {
app.mapDisplay.WindowResized(width, height)
app.gl.Viewport(0, 0, int32(width), int32(height))
}
func (app *Application) onFilesDropped(names []string) {
app.modalState.HandleFiles(names)
}
func (app *Application) onKeyPress(key input.Key, modifier input.Modifier) {
app.lastModifier = modifier
usedGlobalShortcut := true
switch {
case key == input.KeyEscape:
app.modalState.SetState(nil)
case key == input.KeyUndo:
app.tryUndo()
case key == input.KeyRedo:
app.tryRedo()
case key == input.KeyNew:
app.newProject()
case key == input.KeySave:
app.projectView.StartSavingMod()
case key == input.KeyF1 && modifier.IsClear():
*app.projectView.WindowOpen() = !*app.projectView.WindowOpen()
case key == input.KeyF2 && modifier.IsClear():
*app.levelControlView.WindowOpen() = !*app.levelControlView.WindowOpen()
case key == input.KeyF3 && modifier.IsClear():
*app.levelTilesView.WindowOpen() = !*app.levelTilesView.WindowOpen()
case key == input.KeyF4 && modifier.IsClear():
*app.levelObjectsView.WindowOpen() = !*app.levelObjectsView.WindowOpen()
case key == input.KeyF5 && modifier.IsClear():
*app.messagesView.WindowOpen() = !*app.messagesView.WindowOpen()
default:
usedGlobalShortcut = false
}
if usedGlobalShortcut {
return
}
if app.shouldReportKeyToImgui(key) {
imgui.CurrentIO().KeyPress(int(key))
app.updateKeyModifier()
}
}
func (app *Application) onKeyRelease(key input.Key, modifier input.Modifier) {
app.lastModifier = modifier
if app.shouldReportKeyToImgui(key) {
imgui.CurrentIO().KeyRelease(int(key))
app.updateKeyModifier()
}
}
func (app *Application) shouldReportKeyToImgui(key input.Key) bool {
keyIsModifier := key.AsModifier() != input.ModNone
return keyIsModifier || app.guiContext.IsUsingKeyboard()
}
func (app *Application) onChar(char rune) {
if app.guiContext.IsUsingKeyboard() {
imgui.CurrentIO().AddInputCharacters(string(char))
return
}
switch char {
case 'v':
_ = app.levelEditorService.PlaceObjectsOnFloor()
case 'f':
_ = app.levelEditorService.PlaceObjectsOnEyeLevel()
case 'r':
_ = app.levelEditorService.PlaceObjectsOnCeiling()
}
}
func (app *Application) updateKeyModifier() {
imguiIO := imgui.CurrentIO()
imguiIO.KeyCtrl(int(input.KeyControl), int(input.KeyControl))
imguiIO.KeyShift(int(input.KeyShift), int(input.KeyShift))
imguiIO.KeyAlt(int(input.KeyAlt), int(input.KeyAlt))
imguiIO.KeySuper(int(input.KeySuper), int(input.KeySuper))
}
func (app *Application) onModifier(modifier input.Modifier) {
app.lastModifier = modifier
}
func (app *Application) modalActive() bool {
return (app.modalState.State != nil) || (len(app.failureMessage) > 0)
}
func (app *Application) tryUndo() {
if !app.cmdStack.CanUndo() || app.modalActive() {
return
}
imgui.ClearActiveID()
err := app.projectService.ModifyModWith(app.cmdStack.Undo)
if err != nil {
app.onFailure("Undo", "", err)
}
}
func (app *Application) tryRedo() {
if !app.cmdStack.CanRedo() || app.modalActive() {
return
}
imgui.ClearActiveID()
err := app.projectService.ModifyModWith(app.cmdStack.Redo)
if err != nil {
app.onFailure("Redo", "", err)
}
}
func (app *Application) onMouseMove(x, y float32) {
app.lastMouseX = x
app.lastMouseY = y
app.guiContext.SetMousePosition(x, y)
if !app.guiContext.IsUsingMouse() {
app.mapDisplay.MouseMoved(x, y)
}
}
func (app *Application) onMouseScroll(dx, dy float32) {
if !app.guiContext.IsUsingMouse() {
app.mapDisplay.MouseScrolled(app.lastMouseX, app.lastMouseY, dx, dy, app.lastModifier)
}
app.guiContext.MouseScroll(dx, dy)
}
func (app *Application) onMouseButtonDown(buttonMask uint32, modifier input.Modifier) {
if !app.guiContext.IsUsingMouse() {
app.mapDisplay.MouseButtonDown(app.lastMouseX, app.lastMouseY, buttonMask)
}
app.reportButtonChange(buttonMask, true)
}
func (app *Application) onMouseButtonUp(buttonMask uint32, modifier input.Modifier) {
if !app.guiContext.IsUsingMouse() {
app.mapDisplay.MouseButtonUp(app.lastMouseX, app.lastMouseY, buttonMask, modifier)
}
app.reportButtonChange(buttonMask, false)
}
func (app *Application) reportButtonChange(buttonMask uint32, down bool) {
for buttonIndex := 0; buttonIndex < 32; buttonIndex++ {
if (buttonMask & (1 << uint32(buttonIndex))) != 0 {
app.guiContext.MouseButtonChanged(buttonIndex, down)
}
}
}
func (app *Application) initGuiSizes() {
if app.GuiScale < 0.5 {
app.GuiScale = 1.0
} else if app.GuiScale > 10.0 {
app.GuiScale = 10.0
}
if app.FontSize <= 0 {
app.FontSize = 16.0
}
app.FontSize *= app.GuiScale
}
func (app *Application) initGuiStyle() {
if len(app.FontFile) == 0 {
imgui.CurrentIO().SetFontGlobalScale(app.GuiScale)
}
imgui.CurrentStyle().ScaleAllSizes(app.GuiScale)
color := func(r, g, b byte, alpha float32) imgui.Vec4 {
return imgui.Vec4{X: float32(r) / 255.0, Y: float32(g) / 255.0, Z: float32(b) / 255.0, W: alpha}
}
colorDoubleFull := func(alpha float32) imgui.Vec4 { return color(0xC4, 0x38, 0x9F, alpha) }
colorDoubleDark := func(alpha float32) imgui.Vec4 { return color(0x31, 0x01, 0x38, alpha) }
colorTripleFull := func(alpha float32) imgui.Vec4 { return color(0x21, 0xFF, 0x43, alpha) }
colorTripleDark := func(alpha float32) imgui.Vec4 { return color(0x06, 0xCC, 0x94, alpha) }
colorTripleLight := func(alpha float32) imgui.Vec4 { return color(0x51, 0x99, 0x58, alpha) }
style := imgui.CurrentStyle()
style.SetColor(imgui.StyleColorText, colorTripleFull(1.0))
style.SetColor(imgui.StyleColorTextDisabled, colorTripleDark(1.0))
style.SetColor(imgui.StyleColorWindowBg, colorDoubleDark(0.80))
style.SetColor(imgui.StyleColorPopupBg, colorDoubleDark(0.75))
style.SetColor(imgui.StyleColorTitleBgActive, colorTripleLight(1.0))
style.SetColor(imgui.StyleColorFrameBg, colorTripleLight(0.54))
style.SetColor(imgui.StyleColorFrameBgHovered, colorTripleDark(0.4))
style.SetColor(imgui.StyleColorFrameBgActive, colorTripleDark(0.67))
style.SetColor(imgui.StyleColorCheckMark, colorTripleDark(1.0))
style.SetColor(imgui.StyleColorSliderGrabActive, colorTripleDark(1.0))
style.SetColor(imgui.StyleColorButton, colorTripleDark(0.4))
style.SetColor(imgui.StyleColorButtonHovered, colorTripleDark(1.0))
style.SetColor(imgui.StyleColorHeader, colorTripleLight(0.70))
style.SetColor(imgui.StyleColorHeaderHovered, colorTripleDark(0.8))
style.SetColor(imgui.StyleColorHeaderActive, colorTripleDark(1.0))
style.SetColor(imgui.StyleColorResizeGrip, colorTripleDark(0.25))
style.SetColor(imgui.StyleColorResizeGripHovered, colorTripleDark(0.67))
style.SetColor(imgui.StyleColorResizeGripActive, colorTripleDark(0.95))
style.SetColor(imgui.StyleColorTextSelectedBg, colorTripleDark(0.35))
style.SetColor(imgui.StyleColorTab, colorTripleLight(0.54))
style.SetColor(imgui.StyleColorTabHovered, colorTripleLight(0.75))
style.SetColor(imgui.StyleColorTabActive, colorTripleLight(1.0))
style.SetColor(imgui.StyleColorSliderGrab, colorDoubleFull(1.0))
style.SetColor(imgui.StyleColorButtonActive, colorDoubleFull(1.0))
style.SetColor(imgui.StyleColorSeparatorHovered, colorDoubleFull(0.78))
style.SetColor(imgui.StyleColorSeparatorActive, colorTripleLight(1.0))
}
func (app *Application) initSignalling() {
app.cmdStack = new(cmd.Stack)
app.txnBuilder.Commander = app
}
func (app *Application) initModel() {
app.mod = world.NewMod(app.resourcesChanged, app.modReset)
app.cp = text.DefaultCodepage()
app.textLineCache = text.NewLineCache(app.cp, app.mod)
app.textPageCache = text.NewPageCache(app.cp, app.mod)
app.messagesCache = text.NewElectronicMessageCache(app.cp, app.mod)
app.movieCache = movie.NewCache(app.cp, app.mod)
app.soundEffectCache = sound.NewEffectCache(app.mod)
app.levels = edit.NewEditableLevels(&app.txnBuilder, app.mod)
app.levelSelection = edit.NewLevelSelectionService(app.levels)
app.gameObjectsService = edit.NewGameObjectsService(app.mod)
app.levelEditorService = edit.NewLevelEditorService(&app.txnBuilder, app.gameObjectsService, app.levels, app.levelSelection)
app.paletteCache = graphics.NewPaletteCache(app.gl, app.mod)
app.textureCache = graphics.NewTextureCache(app.gl, app.mod)
app.frameCache = graphics.NewFrameCache(app.gl)
app.animationCache = bitmap.NewAnimationCache(app.mod)
}
func (app *Application) resourcesChanged(modifiedIDs []resource.ID, failedIDs []resource.ID) {
app.textLineCache.InvalidateResources(modifiedIDs)
app.textPageCache.InvalidateResources(modifiedIDs)
app.messagesCache.InvalidateResources(modifiedIDs)
app.movieCache.InvalidateResources(modifiedIDs)
app.soundEffectCache.InvalidateResources(modifiedIDs)
app.levels.InvalidateResources(modifiedIDs)
app.paletteCache.InvalidateResources(modifiedIDs)
app.textureCache.InvalidateResources(modifiedIDs)
app.animationCache.InvalidateResources(modifiedIDs)
}
func (app *Application) modReset() {
app.cmdStack = new(cmd.Stack)
}
// nolint: lll
func (app *Application) initView() {
textViewer := media.NewTextViewerService(app.textLineCache, app.textPageCache, app.mod)
textSetter := media.NewTextSetterService(app.cp)
audioViewer := media.NewAudioViewerService(app.movieCache, app.mod)
audioSetter := media.NewAudioSetterService()
movieViewer := media.NewMovieViewerService(app.movieCache, app.mod)
movieSetter := media.NewMovieSetterService(app.cp)
soundEffectViewer := media.NewSoundViewerService(app.soundEffectCache, app.mod)
soundEffectSetter := media.NewSoundSetterService()
soundEffectService := undoable.NewSoundEffectService(edit.NewSoundEffectService(soundEffectViewer, soundEffectSetter), app)
augmentedTextService := undoable.NewAugmentedTextService(edit.NewAugmentedTextService(textViewer, textSetter, audioViewer, audioSetter), app)
movieService := undoable.NewMovieService(edit.NewMovieService(app.cp, movieViewer, movieSetter), app)
app.projectService = edit.NewProjectService(&app.txnBuilder, app.mod)
app.gameStateService = edit.NewGameStateService(&app.txnBuilder)
app.projectView = project.NewView(app.projectService, &app.modalState, app.GuiScale, &app.txnBuilder)
app.archiveView = archives.NewArchiveView(&app.txnBuilder, app.gameStateService, app.mod, app.textLineCache, app.cp, &app.modalState, app.GuiScale, app)
app.levelControlView = levels.NewControlView(app.levels, app.levelSelection, app.levelEditorService, app.GuiScale, app.textLineCache, app.textureCache, &app.txnBuilder)
app.levelTilesView = levels.NewTilesView(app.levelEditorService, app.GuiScale, app.textLineCache, app.textureCache, &app.txnBuilder)
app.levelObjectsView = levels.NewObjectsView(app.gameObjectsService, app.levelEditorService, app.levelSelection, app.gameStateService, app.GuiScale, app.textLineCache, app.textureCache, &app.txnBuilder, app.gl)
app.messagesView = messages.NewMessagesView(app.mod, app.messagesCache, app.cp, app.movieCache, app.textureCache, &app.modalState, app.clipboard, app.GuiScale, app)
app.textsView = texts.NewTextsView(augmentedTextService, &app.modalState, app.clipboard, app.GuiScale)
app.bitmapsView = bitmaps.NewBitmapsView(app.mod, app.textureCache, app.paletteCache, &app.modalState, app.clipboard, app.GuiScale, app)
app.texturesView = textures.NewTexturesView(app.mod, app.textLineCache, app.cp, app.textureCache, app.paletteCache, &app.modalState, app.clipboard, app.GuiScale, app)
app.animationsView = animations.NewAnimationsView(app.mod, app.textureCache, app.paletteCache, app.animationCache, &app.modalState, app.GuiScale, app)
app.moviesView = movies.NewMoviesView(app.mod, app.frameCache, movieService, &app.modalState, app.GuiScale, app)
app.soundEffectsView = sounds.NewSoundEffectsView(soundEffectService, &app.modalState, app.GuiScale)
app.objectsView = objects.NewView(app.mod, app.textLineCache, app.cp, app.textureCache, app.paletteCache, &app.modalState, app.clipboard, app.GuiScale, app)
app.aboutView = about.NewView(app.clipboard, app.GuiScale, app.Version)
app.licensesView = about.NewLicensesView(app.GuiScale)
}
// Queue requests to perform the given command.
func (app *Application) Queue(command cmd.Command) {
err := app.projectService.ModifyModWith(func(modder world.Modder) error {
return app.cmdStack.Perform(command, modder)
})
if err != nil {
app.onFailure("command", "", err)
}
}
func (app *Application) onFailure(source string, details string, err error) {
app.failurePending = true
app.failureMessage = fmt.Sprintf("Source: %v\nDetails: %v\nError: %v", source, details, err)
}
func (app *Application) renderMainMenu() {
windowEntry := func(name string, shortcut string, isOpen *bool) {
if imgui.MenuItemV(name, shortcut, *isOpen, true) {
*isOpen = !*isOpen
}
}
if imgui.BeginMainMenuBar() {
if imgui.BeginMenu("File") {
if imgui.MenuItemV("New Project", "Ctrl+N", false, true) {
app.newProject()
}
if imgui.MenuItemV("Load Project", "", false, true) {
app.loadProject()
}
if imgui.MenuItemV("Save Project", "", false, true) {
app.saveProject()
}
imgui.Separator()
if imgui.MenuItemV("Save Mod", "Ctrl+S", false, true) {
app.projectView.StartSavingMod()
}
windowEntry("Project", "F1", app.projectView.WindowOpen())
imgui.Separator()
if imgui.MenuItem("Exit") {
app.window.SetCloseRequest(true)
}
imgui.EndMenu()
}
if imgui.BeginMenu("Edit") {
if imgui.MenuItemV("Undo", "Ctrl+Z", false, app.cmdStack.CanUndo()) {
app.tryUndo()
}
if imgui.MenuItemV("Redo", "Ctrl+Y / Ctrl+Shift+Z", false, app.cmdStack.CanRedo()) {
app.tryRedo()
}
imgui.EndMenu()
}
if imgui.BeginMenu("Window") {
windowEntry("Archive", "", app.archiveView.WindowOpen())
windowEntry("Level Control", "F2", app.levelControlView.WindowOpen())
windowEntry("Level Tiles", "F3", app.levelTilesView.WindowOpen())
windowEntry("Level Objects", "F4", app.levelObjectsView.WindowOpen())
windowEntry("Messages", "F5", app.messagesView.WindowOpen())
windowEntry("Texts", "", app.textsView.WindowOpen())
windowEntry("Bitmaps", "", app.bitmapsView.WindowOpen())
windowEntry("Textures", "", app.texturesView.WindowOpen())
windowEntry("Animations", "", app.animationsView.WindowOpen())
windowEntry("Movies", "", app.moviesView.WindowOpen())
windowEntry("Sound Effects", "", app.soundEffectsView.WindowOpen())
windowEntry("Game Objects", "", app.objectsView.WindowOpen())
imgui.EndMenu()
}
if imgui.BeginMenu("Help") {
if imgui.MenuItem("About...") {
app.aboutView.Show()
}
if imgui.MenuItem("Licenses") {
app.licensesView.Show()
}
imgui.EndMenu()
}
imgui.EndMainMenuBar()
}
}
func (app *Application) handleFailure() {
if app.failurePending {
imgui.OpenPopup("Failure Message")
app.failurePending = false
}
if imgui.BeginPopupModal("Failure Message") {
imgui.Text(`Something went wrong. This is bad and I am sorry.
You have the option to "Ignore" this and hope for the best.
This action also clears the undo/redo buffer.
Or you can simply "Exit" the application and then restart it.
This action loses any pending changes.
Perhaps you can make something with the details of the error below.
If you can reproduce this, please make a screenshot and
report it with details on how to reproduce it on the
http://www.systemshock.org forums. Thank you!
`)
imgui.Text("Version: " + app.Version)
imgui.Separator()
imgui.Text(app.failureMessage)
imgui.Separator()
if imgui.Button("Ignore") {
app.failureMessage = ""
app.cmdStack = new(cmd.Stack)
imgui.CloseCurrentPopup()
}
imgui.SameLine()
if imgui.Button("Exit") {
app.window.SetCloseRequest(true)
}
imgui.EndPopup()
}
}
const settingsFileExtension = "hacked-project"
func projectFileTypes() []external.TypeInfo {
return []external.TypeInfo{
{
Title: "HackEd Project File (*." + settingsFileExtension + ")",
Extensions: []string{settingsFileExtension},
},
}
}
func (app *Application) newProject() {
app.saveStoredProject()
app.restoreProjectState(projectState{}, "")
}
func (app *Application) loadProject() {
app.saveStoredProject()
external.LoadFile(&app.modalState, projectFileTypes(), func(filename string) error {
state, err := projectStateFromFile(filename)
if err != nil {
return err
}
app.restoreProjectState(state, filename)
return nil
})
}
func (app *Application) saveStoredProject() {
currentFilename := app.projectService.CurrentStateFilename()
if len(currentFilename) > 0 {
currentState := app.currentProjectState()
_ = currentState.SaveTo(currentFilename)
}
}
func (app *Application) saveProject() {
currentState := app.currentProjectState()
currentFilename := app.projectService.CurrentStateFilename()
if (len(currentFilename) == 0) || (currentState.SaveTo(currentFilename) != nil) {
external.SaveFile(&app.modalState, projectFileTypes(), func(filename string) error {
completeFilename := filename
dotExtension := "." + settingsFileExtension
if !strings.HasSuffix(completeFilename, dotExtension) {
completeFilename += dotExtension
}
err := currentState.SaveTo(completeFilename)
if err != nil {
return err
}
app.projectService.SetCurrentStateFilename(completeFilename)
app.window.SetTitleSuffix(completeFilename)
return nil
})
}
}
func (app *Application) currentProjectState() projectState {
projectSettings := app.projectService.CurrentSettings()
gameStateSettings := app.gameStateService.CurrentSettings()
windowOpenByName := app.windowOpenByName()
var openWindows []string
for key, open := range windowOpenByName {
if *open {
openWindows = append(openWindows, key)
}
}
activeLevelIndex := app.levelSelection.CurrentLevelID()
return projectState{
ProjectSettings: &projectSettings,
GameStateSettings: &gameStateSettings,
OpenWindows: openWindows,
ActiveLevelIndex: &activeLevelIndex,
}
}
func (app *Application) saveWorkspace() {
currentProjectState := app.currentProjectState()
currentProjectStateFilename := app.projectService.CurrentStateFilename()
if len(currentProjectStateFilename) > 0 {
err := currentProjectState.SaveTo(currentProjectStateFilename)
if err != nil {
currentProjectStateFilename = ""
} else {
currentProjectState = projectState{}
}
}
lastProjectState := lastProjectState{
projectState: currentProjectState,
LastProject: currentProjectStateFilename,
}
_ = lastProjectState.SaveTo(app.lastProjectStateConfigFilename())
}
func (app *Application) restoreWorkspace() {
lastProjectState := lastProjectStateFromFile(app.lastProjectStateConfigFilename())
projectStateToRestore := lastProjectState.projectState
projectStateFilename := ""
if len(lastProjectState.LastProject) > 0 {
state, err := projectStateFromFile(lastProjectState.LastProject)
if err == nil {
projectStateToRestore = state
projectStateFilename = lastProjectState.LastProject
}
}
app.restoreProjectState(projectStateToRestore, projectStateFilename)
}
func (app *Application) restoreProjectState(state projectState, filename string) {
var projectSettings edit.ProjectSettings
if state.ProjectSettings != nil {
projectSettings = *state.ProjectSettings
}
app.projectService.RestoreProject(projectSettings, filename)
var gameStateSettings edit.GameStateSettings
if state.GameStateSettings != nil {
gameStateSettings = *state.GameStateSettings
}
app.gameStateService.RestoreSettings(gameStateSettings)
windowOpenByName := app.windowOpenByName()
for _, open := range windowOpenByName {
*open = false
}
for _, key := range state.OpenWindows {
open := windowOpenByName[key]
if open != nil {
*open = true
}
}
if len(state.OpenWindows) == 0 {
*app.projectView.WindowOpen() = true
}
activeLevel := world.StartingLevel
if (state.ActiveLevelIndex != nil) && app.levels.IsLevelAvailable(*state.ActiveLevelIndex) {
activeLevel = *state.ActiveLevelIndex
}
app.levelSelection.SetCurrentLevelID(activeLevel)
app.window.SetTitleSuffix(filename)
}
func (app *Application) updateAutoSave() {
status := app.projectService.SaveStatus()
app.window.SetProjectModified(status.FilesModified > 0)
if status.SavePending && (status.SaveIn == 0) {
status.ConfirmPendingSave()
app.projectView.StartSavingMod()
}
}
func (app *Application) windowStateConfigFilename() string {
return filepath.Join(app.ConfigDir, "WindowState.json")
}
func (app *Application) lastProjectStateConfigFilename() string {
return filepath.Join(app.ConfigDir, "LastProjectState.json")
}
func (app *Application) windowOpenByName() map[string]*bool {
return map[string]*bool{
"project": app.projectView.WindowOpen(),
"archive": app.archiveView.WindowOpen(),
"levelControl": app.levelControlView.WindowOpen(),
"levelTiles": app.levelTilesView.WindowOpen(),
"levelObjects": app.levelObjectsView.WindowOpen(),
"messages": app.messagesView.WindowOpen(),
"texts": app.textsView.WindowOpen(),
"bitmaps": app.bitmapsView.WindowOpen(),
"textures": app.texturesView.WindowOpen(),
"animations": app.animationsView.WindowOpen(),
"movies": app.moviesView.WindowOpen(),
"soundEffects": app.soundEffectsView.WindowOpen(),
"gameObjects": app.objectsView.WindowOpen(),
}
}

Some files were not shown because too many files have changed in this diff Show More