11 Commits

Author SHA1 Message Date
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
41 changed files with 7969 additions and 394 deletions
+1
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/
+373
View File
@@ -0,0 +1,373 @@
# AGENTS.md
Guidance for AI coding agents (and new contributors) working on Questshock.
README.md is the user-facing build/usage guide and CONFIG.md documents the
configuration file; this file covers how the code is organised and the rules to
follow when changing it.
## 1. What this project is
Questshock plays the 1994 game System Shock on a standalone VR headset (Meta
Quest 2 is the tested device). It wraps [Shockolate](https://github.com/Interrupt/systemshock),
a cross-platform port of the original engine, in an Android app that:
- runs the unmodified 2D engine (software or OpenGL renderer) and shows its
frame on a head-tracked virtual screen (an OpenXR quad layer);
- shows a menu and an on-screen keyboard as separate OpenXR quads, operated by
a laser pointer from either controller;
- maps controller inputs to game actions through a configurable binding layer;
- persists settings in `/sdcard/questshock/questshock.config.json`.
A desktop (Linux/Windows) build of the same engine exists for development
without a headset; it has none of the Android/OpenXR code.
## 2. Hard rules
These come from the maintainer. Follow them without being asked again.
1. **Never commit.** Do not run `git commit` (or amend, rebase, push), even if
asked to commit. Draft the commit message and show it; the maintainer runs
the commit. Mention any new untracked file that has to be `git add`ed
(typically a new patch under `android/engine-patches/`).
2. **Do not build.** Do not run `make apk`, `./gradlew`, or `make android-studio`
to check that a change compiles. The maintainer deploys from Android Studio.
`make apk` and `make android-studio` overwrite `android/engine.properties`
with container-only paths and break an open Android Studio project. What you
can do is dry-run patches (section 6) and run static consistency checks
(section 12). Say plainly in your report that the change was not built or
tested.
3. **`engine/` and `android/gl4es-src/` are never edited.** They are vendored
snapshots. Every change to them is a numbered patch under
`android/engine-patches/` or `android/gl4es-patches/`, applied to a scratch
copy at build time. (`engine/` is GPLv3, see README section 6.)
4. **Patch numbers stay contiguous** (`01`, `02`, ... with no gap). When you
insert or delete a patch, renumber the rest and update every reference to
the numbers (README patch reference, code comments).
5. **Comments and documentation describe the current state and why it has to be
that way.** No history or narrative: no "used to", "originally", "no
longer", "now", "this was fixed", "confirmed on device", incident stories or
dropped alternatives. History belongs in commit messages. A comment that
would read oddly to someone who never saw an earlier version should be
rewritten or cut. Default to one or two lines. (README section 5.5, the
debugging notes, is the one deliberate exception.)
6. **Standalone headset only, OpenXR first.** No feature may need a host PC
(no PCVR, tethering or streaming). Prefer OpenXR APIs over vendor-specific
ones (Meta's Oculus Mobile SDK and the like) so the port can run on other
Android OpenXR headsets. Flag a request that would break either rule
instead of quietly doing it.
7. **Do not bump `build-image/VERSION`** just because `build-image/Dockerfile`
changed. Bump it only once a build with the current number has succeeded
end to end (for example a CI run). Ask if unsure.
8. **Keep docs in sync with behaviour.** A user-visible change updates
README.md and/or CONFIG.md in the same change; a new or renumbered engine
patch updates the README patch reference (section 5.4).
## 3. Repository layout
| Path | What it is |
|---|---|
| `engine/` | Vendored Shockolate source. Read-only for us. |
| `android/engine-patches/` | Numbered `patch -p1` files applied to a scratch copy of `engine/`. |
| `android/gl4es-src/`, `android/gl4es-patches/` | Vendored GL4ES (desktop GL to GLES) and its patches. |
| `android/app/src/main/cpp/` | First-party native code (section 4). |
| `android/app/src/main/java/de/ladkau/questshock/` | First-party Java (section 4). `org/libsdl/app/` is vendored SDL2 glue (zlib). |
| `android/app/build.gradle` | Gradle project; the `stageEngine` task stages the patched engine. |
| `android/force-rebuild.sh` | Stops Gradle daemons and wipes staged/build output for a fully fresh native build. |
| `build-image/` | Docker build environment (`Dockerfile`, `VERSION`, `prepare-android-project.sh`, `build-apk.sh`, engine build scripts). |
| `res/` | Bundled resources: `questshock.config.default.json`, `controller_profiles/` (JSON + PNG per controller), `assets/` (where the user's own game data goes, git-ignored), `run.sh`/`run.bat`. |
| `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. |
| `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.
### 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 12).
### 4.5 Off-screen views (`OverlayPanel`, `MenuOverlay`, `KeyboardOverlay`)
The view tree is built and drawn from native code and is never attached to a
window. Consequences that shape the UI code:
- Only `ACTION_DOWN`/`ACTION_UP` touches arrive (the laser trigger). There is no
drag, fling or multi-touch. Scrolling is done by a self-drawn scroll bar
(`ScrollBarView` in `MenuOverlay`), not by the framework.
- `View.post()` never runs, so anything that defers work through it does not
work. `ProgressBar` and `SeekBar` are unusable for the same reason.
- Hover and hit-testing use `OverlayPanel.absoluteBounds()`, which subtracts
ancestor scroll offsets.
- Toggling a child's visibility needs an explicit re-measure and re-layout
(see `relayoutAndRedraw()`).
- All user-visible text goes through `Strings.get(key, language)` with an
English, French and German entry, and is re-applied in `applyTranslations()`
when the language changes. "OpenGL" is a brand name and is not translated.
## 5. Configuration system
- `qs_config.c` holds one parsed struct per group (`video_options`, `general`,
`player_position`, `player_controls`), a parse function, an apply function,
and the writer `qs_config_write_file()`. Groups are tagged live-appliable or
restart-required (`QS_*_REQUIRES_RESTART`).
- Load order at startup: `questshock.config.json`, else
`questshock.config.default.json` (bundled, refreshed on every launch), else
the engine's own `prefs.txt` behaviour. Save is only ever an explicit menu
action.
- Invalid values are logged and skipped individually; unknown keys are ignored.
**Adding an option:** parsed-struct field, parse, apply (with validation and a
`WARN` on invalid input), write in `qs_config_write_file()`, restart flag,
`CONFIG.md`, and (if it should have a shipped default) `res/questshock.config.default.json`.
The virtual screen has two profiles, "initial" (splash, cutscenes, main menu)
and "game", switched by `qs_screen_profile_notify_loop_mode()` from engine
patch 22. `s_screen_profile_is_game` in `qs_config.c` doubles as "a game is
running" and gates the cheats and the menu pause.
## 6. Engine patches
Rules:
- Guard every engine change with `#ifdef __ANDROID__` so the desktop build is
unaffected, and keep hunks minimal.
- Patches are applied in filename order with `patch -p1` from the engine root
(paths look like `a/src/GameSrc/hkeyfunc.c`). Patch N is generated against
the tree with patches `01..N-1` applied.
- Some engine files use CRLF line endings (for example `mouselook.c`). Edits
and generated patches must preserve them byte for byte; check with `file`
or `cat -A`. An editor or script that rewrites line endings produces a
whole-file diff.
- Do not hand-edit hunk headers. Regenerate the patch from a scratch copy.
Creating or changing a patch:
1. Make a scratch copy of `engine/` (for example under the session scratch
directory) and apply patches `01..N-1` to it (`patch -p1 -d <scratch> < ...`).
2. Copy the target file(s) to a second location, edit them in the scratch copy.
3. Produce the patch with `diff -u` using `a/` and `b/` path prefixes relative
to the engine root, and save it as `android/engine-patches/NN-android-<topic>.patch`.
4. Dry-run the whole series on a fresh copy of `engine/`
(`patch -p1 --dry-run`, or apply for real on the copy) and confirm every
patch applies cleanly, with the patched file byte-identical to what you
edited.
5. Keep numbering contiguous, and update the README patch reference (5.4) and
any code comments that cite patch numbers.
Gradle's `stageEngine` task re-stages and re-patches on build, but it can wrongly
report `UP-TO-DATE` when a **new** patch file appears. If a native change does
not show up on the device, tell the maintainer to run `./gradlew --stop`, or
`android/force-rebuild.sh`, or delete `build/android-engine`, before assuming
the change is wrong.
Engine symbols used from first-party code (for example `game_paused`,
`pause_game_func`, `toggle_giveall_func`, `qs_cheat_refill`,
`qs_in_cyberspace`) are declared `extern` next to their use in `qs_config.c` or
`xr_input.c`, with a comment naming the engine file and patch.
## 7. Controller mapping (`xr_input.c`)
Structure:
- Physical controls are OpenXR actions, suggested once against three
interaction profiles: Meta Touch, HTC Vive wand and the core
`khr/simple_controller`. A single wrong path makes the whole
`xrSuggestInteractionProfileBindings` call fail, so each profile lists only
components it really has. A failure calls
`qs_config_report_fatal_input_error()` and aborts input init.
- The **raw-input layer** (`QsRawInput`) digitizes every physical control
(buttons, triggers, squeezes, thumbstick clicks, and eight thumbstick
directions with hysteresis) each frame. `QS_RAW_NONE` means unbound.
- **Actions** (`QsControlAction`) are bound to raw inputs through `s_binding[]`.
The default bindings are `kDefaults[]`.
- Contexts: `ALWAYS`, `NORMAL` (move, strafe, turn, jump, stand, crouch, prone)
and `CYBER` (the eleven `cyber_*` actions). The active set is chosen with
`qs_in_cyberspace()` (patch 24). Two actions conflict only if their contexts
can be active together (`xr_input_contexts_overlap()`).
- Capture mode (`xr_input_process_capture()`): the next fresh raw down-edge is
assigned to the chosen action. Any other action in an overlapping context that
held that input (or its symmetric alias) is set to `none`. The reset and the
timeout (8 s) paths never leave a half-assigned state.
- The menu button is not a raw input and can never be bound, so the menu can
always be reached to press **Reset to Defaults**.
- While the menu is open, `mouse_click` is always the trigger (the controller
used to operate the menu); after it closes, the bound `mouse_click` drives
both the keyboard and the game quad.
- While the Controller Options tab is open, the menu's own thumbstick
snap-turn and smooth-move are disabled
(`g_controller_options_tab_open`, set through
`nativeSetControllerOptionsTabOpen`).
- Free view: holding a `free_*_thumbstick_view` binding turns that stick into
the engine's mouse look (cursor forced hidden, stick direction bindings
suspended). It uses `SDL_ShowCursor(SDL_DISABLE)` because the engine zeroes
mouse-look velocity while the cursor is visible.
- Cyberspace pitch and bank scale with stick deflection through the globals
from patch 25 (`qs_cyber_pitch_scale`, `qs_cyber_bank_scale`).
### Adding or changing a bindable action (checklist)
All of these lists share one order and must stay identical:
1. `QsControlAction` enum in `xr_input.c` (and append before `QS_ACTION_COUNT`).
2. `kActionNames[]` in `xr_input.c`.
3. `s_binding[]` initial values and `kDefaults[]` in `xr_input.c`.
4. If it injects a key: a `kKeyActions[]` entry (keycode, modifiers, whether it
needs `SDL_TEXTINPUT`); add the key to the stuck-key watchdog list
(`kWatchedKeys`) if it is held continuously.
5. `ACTION_KEYS` in `MenuOverlay.java`, and `FIRST_ROW_ACTIONS` if it should be
listed first.
6. `Strings.java`: `action_<key>` in EN/FR/DE.
7. `QS_MAX_PLAYER_CONTROL_ACTIONS` in `qs_config.c` if the count outgrows it.
8. `CONFIG.md`: the action tables and the count in the Player Controls intro;
README default-controls table if it has a default binding.
9. Check that no two actions that can be active together share a default input.
### Injecting keys into the engine
- Plain printable keys (letters, digits, Space, punctuation) need an
`SDL_TEXTINPUT` event plus `SDL_KEYDOWN`/`SDL_KEYUP`. Modifier, control-range,
F and keypad keys use `SDL_KEYDOWN`/`SDL_KEYUP` with the right `mod`.
- **Send the release on the real up edge, never in the same call as the
press.** The engine polls movement keys once per frame before it drains
events, so a press and release drained together are never seen and the key
silently does nothing. Tie the release to the physical button's up edge (or
the touch's `ACTION_UP`/`ACTION_CANCEL`).
- Held keys are tracked in the engine's `sshockKeyStates[]`; `xr_input.c`
keeps a watchdog that clears a watched key the engine still thinks is down
after its binding is released.
- Actions that share an engine key (for example the several ways to fire, or
the movement keys) are handled with sibling logic in the dispatch loop
(`wantDown[]`); do not press or release the shared key independently.
## 8. Cheats tab
- Buttons and switches live in `MenuOverlay.buildCheatsTab()`; ids are
`QS_CHEAT_*` in `qs_config.c` and `CHEAT_*` in `MenuOverlay.java` (keep them
identical).
- The engine's own cheat handlers (`toggle_giveall_func`, `toggle_physics_func`,
`toggle_up_level_func`, `toggle_down_level_func`) are called on the engine
thread through `qs_apply_cheat()`, and only while a game is running.
- The four unlimited switches are a bitmask in `qs_config.c`
(`QS_CHEAT_MASK_*`: health 1, energy 2, ammo 4, items 8). Every frame
`qs_config_frame_tick()` calls `qs_cheat_refill(mask)` (patch 26). The mask
values must match the patch. The switches are not persisted.
- The four `cheat_*` hotkeys are deliberately not bindable in
`player_controls`. New cheats go on the Cheats tab.
## 9. Menu pause
Opening the menu pauses a running game (`qs_apply_menu_pause()` through the
menu-visibility event); closing it resumes only if the menu did the pausing and
the game is still paused. Do not toggle pause blindly, since the pause function
toggles.
## 10. Bundled files on the device
`QuestShockActivity` extracts these into `/sdcard/questshock/`:
- shaders, the default soundfont and `GET_ASSETS_QUEST.txt` (once);
- `questshock.config.default.json` and `controller_profiles/` (every launch,
overwriting only the bundled filenames so user-added profiles survive).
`res/controller_profiles/` and the default config are staged into APK assets by
`build-image/prepare-android-project.sh`; new staged directories must also be
declared as `inputs.dir(...)` of `stageEngine` in `android/app/build.gradle`.
The proprietary game data (`res/data/`, `res/sound/`) is never bundled or
committed. The app refuses to start the engine until it is present.
## 11. Code and documentation style
- 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(...)`.
## 12. 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`).
## 13. 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.
+115 -12
View File
@@ -6,6 +6,17 @@ An open source project to play the classic 1994 System Shock on a VR
headset, built on top of [Shockolate](https://github.com/Interrupt/systemshock), headset, built on top of [Shockolate](https://github.com/Interrupt/systemshock),
a cross-platform port of the original game. a cross-platform port of the original game.
The game is played on a virtual screen: the engine renders its usual flat 2D
frame, and Questshock shows it as a large screen floating in front of you in
VR. It is not a stereoscopic 3D conversion; you sit or stand in front of the
screen the way you would in front of a monitor or a cinema screen. The
screen's width and distance can be set separately for the menus/intro and for
gameplay. Both controllers act as laser pointers for the game's mouse-driven
interface and for a VR menu and on-screen keyboard, and every controller
button, stick direction and game hotkey can be assigned to a game action.
Questshock runs entirely on the headset (tested on a Meta Quest 2) as a
sideloaded Android app, and needs your own copy of the game data.
## 1. Design principles ## 1. Design principles
- **Standalone-headset-only.** The game must run entirely on the headset - **Standalone-headset-only.** The game must run entirely on the headset
@@ -31,6 +42,10 @@ a cross-platform port of the original game.
"3. Game assets" below); `res/assets/extract_assets.sh` extracts it into "3. Game assets" below); `res/assets/extract_assets.sh` extracts it into
`ss_ee/`. `ss_ee/`.
- `res/run.sh` - the launcher script, copied into `dist/` on build. - `res/run.sh` - the launcher script, copied into `dist/` on build.
- `res/questshock.config.default.json` - the bundled default configuration
(see [CONFIG.md](CONFIG.md)); `res/controller_profiles/` - the per-controller
illustrations and labels shown in the Controller Options tab (see
[CONFIG.md](CONFIG.md)). Both are copied into the APK.
- `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`).
@@ -90,6 +105,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
@@ -203,18 +237,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
@@ -270,6 +303,54 @@ debugging enabled.)
`/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
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
@@ -508,6 +589,28 @@ 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.
`android/gl4es-patches/`: `android/gl4es-patches/`:
+39 -4
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,14 @@ 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")
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
@@ -171,7 +199,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"
abiFilters 'arm64-v8a' abiFilters 'arm64-v8a'
} }
} }
@@ -189,6 +217,13 @@ android {
} }
} }
// 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
} }
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
@@ -10,6 +10,8 @@
#include <SDL.h> #include <SDL.h>
#include "qs_config.h"
#include "xr_input.h"
#include "xr_overlay.h" #include "xr_overlay.h"
#include "xr_session.h" #include "xr_session.h"
@@ -88,3 +90,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);
+427 -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,51 @@ 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_resolution(int *width, int *height) { void xr_get_game_resolution(int *width, int *height) {
+83 -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,64 @@ 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);
// 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;
@@ -39,7 +39,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())
@@ -143,11 +144,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
@@ -211,8 +226,8 @@ public class QuestShockActivity extends SDLActivity {
.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 +247,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 +261,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];
}
}
+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
+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
+3 -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,8 @@ 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"
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
+64 -6
View File
@@ -13,10 +13,23 @@
# inside it in sshock.kpf, which is just a zip file. Its res/pc/hd/ and # inside it in sshock.kpf, which is just a zip file. Its res/pc/hd/ and
# res/pc/cdrom/ trees together hold every classic-game data/sound file # res/pc/cdrom/ trees together hold every classic-game data/sound file
# Shockolate's res/ folder expects (res/pc/hd wins the couple of # Shockolate's res/ folder expects (res/pc/hd wins the couple of
# filenames present in both: intro.res, objprop.dat). Needs innoextract # filenames present in both: intro.res, objprop.dat), EXCEPT
# and unzip; if they're not installed locally, this script does the # mfdfrn.res/mfdger.res (French/German MFD art - see CONFIG.md/README.md),
# extraction inside a throwaway Ubuntu container instead (needs docker in # which that installer doesn't include at all - see "Optional: French/
# that case, nothing else). # German MFD art" below for where those instead come from. Needs
# innoextract and unzip; if they're not installed locally, this script
# does the extraction inside a throwaway Ubuntu container instead (needs
# docker in that case, nothing else).
#
# Optional: French/German MFD art
# The Enhanced Edition installer's data set doesn't include
# mfdfrn.res/mfdger.res, so French/German are otherwise missing their
# MFD (Multi-Function Display) art and the game refuses to switch to
# them (see CONFIG.md). System Shock: Classic (also gog.com) does
# include both files. If you also own it, drop its offline installer
# (e.g. setup_system_shock_classic_2.0.0.3.exe) in this directory too -
# entirely optional, this script still runs fine without it - and this
# script pulls just those two files out of it into ss_ee/data/ as well.
set -euo pipefail set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")" cd "$(dirname "${BASH_SOURCE[0]}")"
@@ -27,6 +40,7 @@ fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; }
shopt -s nullglob shopt -s nullglob
installers=(setup_system_shock_enhanced_edition*.exe) installers=(setup_system_shock_enhanced_edition*.exe)
classic_installers=(setup_system_shock_classic*.exe)
shopt -u nullglob shopt -u nullglob
case "${#installers[@]}" in case "${#installers[@]}" in
0) fail "no setup_system_shock_enhanced_edition*.exe found in $ASSETS_DIR - buy System Shock: Enhanced Edition on gog.com, download the offline installer, and drop it here" ;; 0) fail "no setup_system_shock_enhanced_edition*.exe found in $ASSETS_DIR - buy System Shock: Enhanced Edition on gog.com, download the offline installer, and drop it here" ;;
@@ -35,6 +49,15 @@ case "${#installers[@]}" in
esac esac
INSTALLER="${installers[0]}" INSTALLER="${installers[0]}"
# Optional - see "Optional: French/German MFD art" above. Empty (not
# missing/failed) when absent; only a genuine ambiguity (more than one
# candidate file) is a hard failure.
case "${#classic_installers[@]}" in
0) CLASSIC_INSTALLER="" ;;
1) CLASSIC_INSTALLER="${classic_installers[0]}" ;;
*) fail "multiple setup_system_shock_classic*.exe files found in $ASSETS_DIR - keep only one: ${classic_installers[*]}" ;;
esac
# Extracts sshock.kpf from $1 (an installer .exe) and assembles $2 (an # Extracts sshock.kpf from $1 (an installer .exe) and assembles $2 (an
# ss_ee output directory) from its res/pc/{hd,cdrom} trees. Defined as a # ss_ee output directory) from its res/pc/{hd,cdrom} trees. Defined as a
# function so the exact same logic can run either natively or, via # function so the exact same logic can run either natively or, via
@@ -69,9 +92,38 @@ run_extraction() {
cp -a "$work/kpf/res/pc/hd/sound/." "$out_dir/sound/" cp -a "$work/kpf/res/pc/hd/sound/." "$out_dir/sound/"
} }
# Extracts mfdfrn.res/mfdger.res from $1 (a System Shock: Classic installer
# .exe) into $2 (an existing ss_ee output directory's data/ subfolder) -
# see "Optional: French/German MFD art" above. Unlike the Enhanced Edition
# installer, Classic's data files sit directly in the Inno Setup package
# (no intermediate zip), so this is just a targeted innoextract --include,
# no unzip step needed. Same declare -f-into-docker structure as
# run_extraction() above.
run_classic_mfd_extraction() {
set -euo pipefail
local installer="$1" out_dir="$2"
work="$(mktemp -d)"
trap 'rm -rf "${work:-}"' EXIT
echo "== Extracting mfdfrn.res/mfdger.res from classic installer =="
innoextract --silent \
--include 'app/SSHOCK/DATA/MFDFRN.RES' --include 'app/SSHOCK/DATA/MFDGER.RES' \
-d "$work/classic" "$installer"
# Original DOS-era filenames are uppercase; Shockolate's resource
# loader expects the lowercase names already used for every other
# res/data/*.res file (case-sensitive on Linux, unlike the DOS/Windows
# filesystems these came from).
cp "$work/classic/app/SSHOCK/DATA/MFDFRN.RES" "$out_dir/data/mfdfrn.res"
cp "$work/classic/app/SSHOCK/DATA/MFDGER.RES" "$out_dir/data/mfdger.res"
}
if command -v innoextract >/dev/null 2>&1 && command -v unzip >/dev/null 2>&1; then if command -v innoextract >/dev/null 2>&1 && command -v unzip >/dev/null 2>&1; then
echo "== Using local innoextract/unzip ==" echo "== Using local innoextract/unzip =="
run_extraction "$ASSETS_DIR/$INSTALLER" "$OUT_DIR" run_extraction "$ASSETS_DIR/$INSTALLER" "$OUT_DIR"
if [ -n "$CLASSIC_INSTALLER" ]; then
run_classic_mfd_extraction "$ASSETS_DIR/$CLASSIC_INSTALLER" "$OUT_DIR"
fi
else else
echo "== innoextract/unzip not found locally - using a throwaway docker container ==" echo "== innoextract/unzip not found locally - using a throwaway docker container =="
command -v docker >/dev/null 2>&1 \ command -v docker >/dev/null 2>&1 \
@@ -86,12 +138,18 @@ else
apt-get update -qq >/dev/null apt-get update -qq >/dev/null
apt-get install -y -qq innoextract unzip >/dev/null apt-get install -y -qq innoextract unzip >/dev/null
$(declare -f run_extraction) $(declare -f run_extraction)
$(declare -f run_classic_mfd_extraction)
run_extraction '/assets/$INSTALLER' '/assets/ss_ee' run_extraction '/assets/$INSTALLER' '/assets/ss_ee'
$([ -n "$CLASSIC_INSTALLER" ] && echo "run_classic_mfd_extraction '/assets/$CLASSIC_INSTALLER' '/assets/ss_ee'")
chown -R \"\$HOST_UID:\$HOST_GID\" /assets/ss_ee chown -R \"\$HOST_UID:\$HOST_GID\" /assets/ss_ee
" "
fi fi
echo "== Done ==" echo "== Done =="
echo "Assets extracted to $OUT_DIR" echo "Assets extracted to $OUT_DIR"
echo "Note: mfdfrn.res/mfdger.res (French/German MFD art) are not present in this" if [ -n "$CLASSIC_INSTALLER" ]; then
echo "installer and are only needed for those localizations - not for English play." echo "Also extracted mfdfrn.res/mfdger.res (French/German MFD art) from $CLASSIC_INSTALLER."
else
echo "Note: mfdfrn.res/mfdger.res (French/German MFD art) are not present - only needed for"
echo "French/German (see CONFIG.md); drop a System Shock: Classic installer here too to get them."
fi
+9
View File
@@ -0,0 +1,9 @@
{
"name": "Generic OpenXR Controller",
"interaction_profile": "/interaction_profiles/khr/simple_controller",
"image": "generic.png",
"controls": [
{"id": "left_trigger", "label": "Select (Left)", "anchor_x": 0.30, "anchor_y": 0.60},
{"id": "right_trigger", "label": "Select (Right)", "anchor_x": 0.30, "anchor_y": 0.60}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+19
View File
@@ -0,0 +1,19 @@
{
"name": "Meta Quest Touch",
"interaction_profile": "/interaction_profiles/oculus/touch_controller",
"image": "touch.png",
"controls": [
{"id": "left_trigger", "label": "Trigger", "anchor_x": 0.30, "anchor_y": 0.45},
{"id": "right_trigger", "label": "Trigger", "anchor_x": 0.72, "anchor_y": 0.45},
{"id": "left_squeeze", "label": "Grip", "anchor_x": 0.12, "anchor_y": 0.55},
{"id": "right_squeeze", "label": "Grip", "anchor_x": 0.58, "anchor_y": 0.55},
{"id": "left_x", "label": "X", "anchor_x": 0.14, "anchor_y": 0.33},
{"id": "left_y", "label": "Y", "anchor_x": 0.24, "anchor_y": 0.35},
{"id": "right_a", "label": "A", "anchor_x": 0.68, "anchor_y": 0.35},
{"id": "right_b", "label": "B", "anchor_x": 0.75, "anchor_y": 0.33},
{"id": "left_thumbstick", "label": "Thumbstick", "anchor_x": 0.20, "anchor_y": 0.28},
{"id": "right_thumbstick", "label": "Thumbstick", "anchor_x": 0.62, "anchor_y": 0.30},
{"id": "left_thumbstick_click", "label": "Thumbstick Click", "anchor_x": 0.20, "anchor_y": 0.28},
{"id": "right_thumbstick_click", "label": "Thumbstick Click", "anchor_x": 0.62, "anchor_y": 0.30}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

+15
View File
@@ -0,0 +1,15 @@
{
"name": "HTC Vive Wand",
"interaction_profile": "/interaction_profiles/htc/vive_controller",
"image": "vive.png",
"controls": [
{"id": "left_trigger", "label": "Trigger", "anchor_x": 0.13, "anchor_y": 0.62},
{"id": "right_trigger", "label": "Trigger", "anchor_x": 0.78, "anchor_y": 0.62},
{"id": "left_squeeze", "label": "Grip", "anchor_x": 0.08, "anchor_y": 0.68},
{"id": "right_squeeze", "label": "Grip", "anchor_x": 0.85, "anchor_y": 0.68},
{"id": "left_thumbstick", "label": "Trackpad", "anchor_x": 0.20, "anchor_y": 0.42},
{"id": "right_thumbstick", "label": "Trackpad", "anchor_x": 0.72, "anchor_y": 0.42},
{"id": "left_thumbstick_click", "label": "Trackpad Click", "anchor_x": 0.20, "anchor_y": 0.42},
{"id": "right_thumbstick_click", "label": "Trackpad Click", "anchor_x": 0.72, "anchor_y": 0.42}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

+17
View File
@@ -0,0 +1,17 @@
{
"video_options": {
"renderer": "opengl",
"resolution": "1024x768",
"initial_screen_width_m": 1.600000,
"initial_screen_distance_m": 2.000000,
"game_screen_width_m": 3.400000,
"game_screen_distance_m": 2.000000,
"screen_width_offset_m": 1.000000,
"screen_width_step_m": 0.600000,
"screen_distance_offset_m": 1.000000,
"screen_distance_step_m": 0.500000
},
"general": {
"language": "english"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 435 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB