diff --git a/CONFIG.md b/CONFIG.md index aa839ca..faab213 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -66,6 +66,21 @@ startup, in addition to (and independent of) the engine's own `prefs.txt`. "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", + "toggle_freelook": "right_thumbstick_click", + "fire_weapon": "right_trigger" } } ``` @@ -177,13 +192,14 @@ 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 separate, live-only action, not a -config value: it recenters using your *current* real head pose, so the -game quad ends up directly ahead of wherever you're actually standing/ -facing right now - not a plain "go back to `(0, 0, 0)`" (which would only -undo thumbstick movement, not any physical drift/rotation since the -session started). It's unrelated to `questshock.config.json` - it never -reads or writes this file. +**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` @@ -198,3 +214,196 @@ Which way the player is virtually facing, in degrees. - **Type**: number - **Restart required?**: No (live-appliable) + +## Player Controls (`player_controls`) + +Maps each of 45 in-game actions to a physical controller input: 9 +movement/click actions plus 36 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). +- **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 | + +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. + +`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 36 bindable actions. `stand` (`right_thumbstick_up`), `prone` +(`right_thumbstick_down`), `toggle_freelook` (`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 | +| `toggle_freelook` | `f` | Toggle mouse-look | +| `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. +- The 4 `cheat_*` entries: debug cheats. +- `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. diff --git a/android/app/build.gradle b/android/app/build.gradle index 38aab1c..08b1802 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -106,6 +106,7 @@ def stageEngineTask = tasks.register("stageEngine", Exec) { 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-gl4es") outputs.dir("${repoRoot}/build/android-prebuilt") diff --git a/android/app/src/main/cpp/qs_config.c b/android/app/src/main/cpp/qs_config.c index cbf5cb5..1519657 100644 --- a/android/app/src/main/cpp/qs_config.c +++ b/android/app/src/main/cpp/qs_config.c @@ -369,6 +369,14 @@ typedef struct { bool has_x, has_z, has_yaw_degrees; } QsParsedPlayerPosition; +// One string slot per remappable action, indexed by +// xr_input_action_index_from_name(). This file does not list the actions +// itself; xr_input.c owns them. Sized above the current action count. +#define QS_MAX_PLAYER_CONTROL_ACTIONS 96 +typedef struct { + char bindings[QS_MAX_PLAYER_CONTROL_ACTIONS][24]; // "" = not present in the file +} QsParsedPlayerControls; + typedef struct { QsParsedVideoOptions video_options; bool has_video_options; @@ -376,6 +384,8 @@ typedef struct { bool has_general; QsParsedPlayerPosition player_position; bool has_player_position; + QsParsedPlayerControls player_controls; + bool has_player_controls; } QsParsedConfig; static void qs_parse_video_options_field(const char *key, QsJsonParser *p, void *ctx) { @@ -428,6 +438,17 @@ static void qs_parse_player_position_field(const char *key, QsJsonParser *p, voi qs_json_skip_value(p); } +// Looks up `key` in xr_input.c's action-name list instead of a fixed strcmp +// chain. +static void qs_parse_player_controls_field(const char *key, QsJsonParser *p, void *ctx) { + QsParsedPlayerControls *out = (QsParsedPlayerControls *)ctx; + int index; + if (xr_input_action_index_from_name(key, &index) && index < QS_MAX_PLAYER_CONTROL_ACTIONS) + qs_json_take_string_field(p, out->bindings[index], sizeof(out->bindings[index])); + else + qs_json_skip_value(p); +} + static void qs_parse_top_level_field(const char *key, QsJsonParser *p, void *ctx) { QsParsedConfig *out = (QsParsedConfig *)ctx; if (strcmp(key, "video_options") == 0 && !p->error && p->p < p->end && *p->p == '{') @@ -437,6 +458,9 @@ static void qs_parse_top_level_field(const char *key, QsJsonParser *p, void *ctx else if (strcmp(key, "player_position") == 0 && !p->error && p->p < p->end && *p->p == '{') out->has_player_position = qs_json_parse_object(p, qs_parse_player_position_field, &out->player_position); + else if (strcmp(key, "player_controls") == 0 && !p->error && p->p < p->end && *p->p == '{') + out->has_player_controls = + qs_json_parse_object(p, qs_parse_player_controls_field, &out->player_controls); else qs_json_skip_value(p); } @@ -874,6 +898,38 @@ static bool qs_apply_player_position(const QsParsedPlayerPosition *parsed) { return changed; } +// =========================================================================== +// General: player controls +// =========================================================================== + +// Live-appliable: xr_input_set_action_binding() (xr_input.c) just writes +// into the per-frame lookup table xr_input_sync_and_draw() already reads +// every frame - no restart distinction needed, same as player_position. +#define QS_PLAYER_CONTROLS_REQUIRES_RESTART false + +// Applies only the actions present in *parsed and leaves the rest untouched. +// Invalid raw-input names (a typo, or a symmetric form on the wrong action - +// see xr_input_set_action_binding()) are WARN+skipped per action. +static bool qs_apply_player_controls(const QsParsedPlayerControls *parsed) { + bool changed = false; + int count = xr_input_action_count(); + if (count > QS_MAX_PLAYER_CONTROL_ACTIONS) + count = QS_MAX_PLAYER_CONTROL_ACTIONS; + for (int i = 0; i < count; i++) { + if (parsed->bindings[i][0] == '\0') + continue; + int before = xr_input_get_action_binding(i); + if (!xr_input_set_action_binding(i, parsed->bindings[i])) { + WARN("%s: player_controls.%s=\"%s\" is invalid - ignoring", QS_CONFIG_FILENAME, + xr_input_action_name(i), parsed->bindings[i]); + continue; + } + if (xr_input_get_action_binding(i) != before) + changed = true; + } + return changed; +} + // =========================================================================== // Read-only queries for the Video Options tab (see qs_config.h) // =========================================================================== @@ -1013,6 +1069,11 @@ static void qs_config_apply_parsed(const QsParsedConfig *parsed, bool live, char if (changed && QS_PLAYER_POSITION_REQUIRES_RESTART) qs_append_restart_reason(restart_needed, restart_needed_size, "player_position"); } + if (parsed->has_player_controls) { + bool changed = qs_apply_player_controls(&parsed->player_controls); + if (changed && QS_PLAYER_CONTROLS_REQUIRES_RESTART) + qs_append_restart_reason(restart_needed, restart_needed_size, "player_controls"); + } } static bool qs_config_write_file(void) { @@ -1037,6 +1098,14 @@ static bool qs_config_write_file(void) { fprintf(f, " \"general\": {\n"); fprintf(f, " \"language\": \"%s\"\n", qs_current_language_name()); fprintf(f, " },\n"); + { + int count = xr_input_action_count(); + fprintf(f, " \"player_controls\": {\n"); + for (int i = 0; i < count; i++) + fprintf(f, " \"%s\": \"%s\"%s\n", xr_input_action_name(i), + xr_input_get_action_binding_name(i), i + 1 < count ? "," : ""); + fprintf(f, " },\n"); + } { float x, z, yaw_degrees; xr_session_get_player_transform(&x, &z, &yaw_degrees); @@ -1117,6 +1186,52 @@ static void qs_show_restart_dialog(const char *restart_needed) { // only qs_apply_language()'s missing-resource-files warning above. static void qs_show_message_dialog(const char *message) { qs_call_dialog_method("showMessageDialog", message); } +// Non-cancelable, single-"Exit"-button dialog (MenuOverlay.java's +// showFatalControllerBindingError()) - see qs_config_report_fatal_input_error()'s +// own comment (qs_config.h) on when/why this fires. +void qs_config_report_fatal_input_error(const char *profile_display_name) { + char message[256]; + snprintf(message, sizeof(message), + "Controller support failed to initialize: the OpenXR runtime rejected the \"%s\" " + "interaction profile's bindings. This is an app bug, not something you can fix.", + profile_display_name); + qs_call_dialog_method("showFatalControllerBindingError", message); +} + +// Shared by qs_config_notify_control_capture_result() below - resolves +// MenuOverlay.onControlCaptureResult(int, String), called once per changed +// row (up to twice per capture - see this function's own header comment). +static void qs_call_control_capture_result(int action_index, const char *raw_name) { + JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv(); + jobject activity = (jobject)SDL_AndroidGetActivity(); + if (!env || !activity) + return; + + jclass menuOverlayClass = qs_find_class(env, activity, "de.ladkau.questshock.MenuOverlay"); + if (!menuOverlayClass) + return; + + jmethodID method = (*env)->GetStaticMethodID(env, menuOverlayClass, "onControlCaptureResult", + "(ILjava/lang/String;)V"); + if (!method) { + (*env)->ExceptionClear(env); + return; + } + + jstring rawNameStr = raw_name ? (*env)->NewStringUTF(env, raw_name) : NULL; + (*env)->CallStaticVoidMethod(env, menuOverlayClass, method, (jint)action_index, rawNameStr); + if ((*env)->ExceptionCheck(env)) + (*env)->ExceptionClear(env); +} + +void qs_config_notify_control_capture_result(int action_index, const char *raw_name, + int displaced_action_index, + const char *displaced_raw_name) { + qs_call_control_capture_result(action_index, raw_name); + if (displaced_action_index >= 0) + qs_call_control_capture_result(displaced_action_index, displaced_raw_name); +} + // =========================================================================== // Public API (see qs_config.h) // =========================================================================== @@ -1131,16 +1246,21 @@ static Uint32 s_set_initial_screen_width_event_type; static Uint32 s_set_initial_screen_distance_event_type; static Uint32 s_set_game_screen_width_event_type; static Uint32 s_set_game_screen_distance_event_type; +static Uint32 s_begin_control_capture_event_type; +static Uint32 s_cancel_control_capture_event_type; +static Uint32 s_reset_player_controls_event_type; +static Uint32 s_menu_visibility_event_type; void qs_config_load_startup(void) { // Registers the custom SDL event types qs_config_request_save()/_load()/ // _set_renderer()/_set_resolution()/_set_language()/_reset_position()/ - // the four _set_*_screen_*() screen-profile setters enqueue - done - // here, once, on the engine's own thread and before the menu/its - // buttons could possibly exist yet, rather than lazily from those - // functions themselves (which run on Android's UI thread and could - // otherwise race each other doing this lazily). - Uint32 base = SDL_RegisterEvents(10); + // the four _set_*_screen_*() screen-profile setters/the three + // control-capture/reset requests enqueue - done here, once, on the + // engine's own thread and before the menu/its buttons could possibly + // exist yet, rather than lazily from those functions themselves (which + // run on Android's UI thread and could otherwise race each other doing + // this lazily). + Uint32 base = SDL_RegisterEvents(14); s_save_event_type = base; s_load_event_type = base + 1; s_set_renderer_event_type = base + 2; @@ -1151,6 +1271,10 @@ void qs_config_load_startup(void) { s_set_game_screen_width_event_type = base + 7; s_set_initial_screen_distance_event_type = base + 8; s_set_game_screen_distance_event_type = base + 9; + s_begin_control_capture_event_type = base + 10; + s_cancel_control_capture_event_type = base + 11; + s_reset_player_controls_event_type = base + 12; + s_menu_visibility_event_type = base + 13; QsParsedConfig parsed; bool haveConfig = qs_config_read_file(&parsed); @@ -1266,6 +1390,65 @@ void qs_config_request_set_game_screen_distance(int preset_index) { SDL_PushEvent(&event); } +// Controller Options tab (see qs_config.h) - same enqueue-only reasoning as +// every button above. +void qs_config_request_begin_control_capture(int action_index) { + SDL_Event event; + SDL_zero(event); + event.type = s_begin_control_capture_event_type; + event.user.code = action_index; + SDL_PushEvent(&event); +} + +void qs_config_request_cancel_control_capture(void) { + SDL_Event event; + SDL_zero(event); + event.type = s_cancel_control_capture_event_type; + SDL_PushEvent(&event); +} + +void qs_config_notify_menu_visibility(bool visible) { + SDL_Event event; + SDL_zero(event); + event.type = s_menu_visibility_event_type; + event.user.code = visible ? 1 : 0; + SDL_PushEvent(&event); +} + +void qs_config_request_reset_player_controls(void) { + SDL_Event event; + SDL_zero(event); + event.type = s_reset_player_controls_event_type; + SDL_PushEvent(&event); +} + +// hkeyfunc.c - the same toggle the game's own "pause" hotkey ('p') calls, +// which also handles the mouse capture/cursor/sound side of pausing. +extern uchar game_paused; +extern uchar pause_game_func(ushort keycode, uint32_t context, intptr_t data); + +// True only while a pause this file caused is (as far as it knows) still in +// effect - see qs_config_notify_menu_visibility(). +static bool s_menu_paused_game = false; + +// s_screen_profile_is_game is "a game is running" (anything but the setup/ +// main-menu and cutscene loops, see qs_screen_profile_notify_loop_mode()). +static void qs_apply_menu_pause(bool menu_visible) { + if (menu_visible) { + if (s_screen_profile_is_game && !game_paused) { + pause_game_func(0, 0, 0); + s_menu_paused_game = true; + } + } else if (s_menu_paused_game) { + s_menu_paused_game = false; + // Only toggle back if it's still paused - the game may have + // unpaused itself (or the player did) while the menu was open, and + // toggling then would pause it instead. + if (game_paused) + pause_game_func(0, 0, 0); + } +} + bool qs_config_handle_event(const SDL_Event *event) { if (event->type == s_save_event_type) { if (qs_config_write_file()) @@ -1305,15 +1488,10 @@ bool qs_config_handle_event(const SDL_Event *event) { return true; } if (event->type == s_reset_position_event_type) { - // Deliberately not xr_session_set_player_transform(0,0,0) - see - // xr_input_recenter_player_to_screen()'s own comment: a "return to - // (0,0,0)" reset only undoes thumbstick locomotion, not any real, - // physical drift/rotation since the session started, which is what - // actually made this feel "slightly off" - recentering onto the - // player's current real head pose fixes both at once, and is - // unrelated to questshock.config.json (this event doesn't read or - // write the config file at all, just like this one never has). - xr_input_recenter_player_to_screen(); + // Re-anchors the position onto the player's current real head position + // and keeps their facing (see xr_input_reset_player_position()). Does + // not read or write questshock.config.json. + xr_input_reset_player_position(); message_info("Recentered"); return true; } @@ -1345,5 +1523,29 @@ bool qs_config_handle_event(const SDL_Event *event) { qs_preset_value(s_screen_distance_offset_m, s_screen_distance_step_m, index)); return true; } + if (event->type == s_begin_control_capture_event_type) { + xr_input_capture_begin(event->user.code); + return true; + } + if (event->type == s_cancel_control_capture_event_type) { + xr_input_capture_cancel(); + return true; + } + if (event->type == s_menu_visibility_event_type) { + qs_apply_menu_pause(event->user.code != 0); + return true; + } + if (event->type == s_reset_player_controls_event_type) { + // Built-in defaults first (they cover every action), then + // questshock.config.default.json's player_controls group, if it has + // one, overrides the actions it specifies. + xr_input_reset_player_controls(); + QsParsedConfig defaultParsed; + if (qs_config_read_file_named(QS_CONFIG_DEFAULT_FILENAME, &defaultParsed) && + defaultParsed.has_player_controls) + qs_apply_player_controls(&defaultParsed.player_controls); + message_info("Controller bindings reset to defaults"); + return true; + } return false; } diff --git a/android/app/src/main/cpp/qs_config.h b/android/app/src/main/cpp/qs_config.h index 8179466..194791e 100644 --- a/android/app/src/main/cpp/qs_config.h +++ b/android/app/src/main/cpp/qs_config.h @@ -56,14 +56,10 @@ void qs_config_request_set_resolution(int resolution_index); // 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 reasoning as the three above - called from the -// Main Menu tab's "Reset Position" button. A pure, live "return to a sane -// view of the game quad" action (see xr_input.h's own -// xr_input_recenter_player_to_screen(), which this dispatches to) - -// unrelated to questshock.config.json despite living in this file for the -// cross-thread dispatch machinery: it never reads or writes the config -// file, and isn't a "reset to (0,0,0)" - it recenters based on the -// player's current real head pose instead. +// 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 @@ -81,14 +77,45 @@ 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); -// Returns true and handles the event if it's one of the ten custom events -// qs_config_request_save()/_load()/_set_renderer()/_set_resolution()/ -// _set_language()/_reset_position()/the four _set_*_screen_*() setters -// enqueue - called from pump_events() (sdl_events.c, see -// android/engine-patches/21-android-config.patch) on the engine's own -// thread, once per polled event, before its normal switch on well-known -// SDL_* event types. Returns false (no-op) for any other event type, -// including before the ten custom types have even been registered. +// 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); + +// 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 diff --git a/android/app/src/main/cpp/questshock_native.c b/android/app/src/main/cpp/questshock_native.c index 7b79921..f7afcc3 100644 --- a/android/app/src/main/cpp/questshock_native.c +++ b/android/app/src/main/cpp/questshock_native.c @@ -11,6 +11,7 @@ #include #include "qs_config.h" +#include "xr_input.h" #include "xr_overlay.h" #include "xr_session.h" @@ -233,3 +234,39 @@ 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); +} diff --git a/android/app/src/main/cpp/xr_input.c b/android/app/src/main/cpp/xr_input.c index 32ffcd2..b953864 100644 --- a/android/app/src/main/cpp/xr_input.c +++ b/android/app/src/main/cpp/xr_input.c @@ -8,6 +8,7 @@ #include #include +#include "qs_config.h" #include "xr_mouse.h" #include "xr_overlay.h" #include "xr_session.h" @@ -51,27 +52,315 @@ static const float kHandColor[2][4] = { #define MENU_TITLE_BAR_V_FRACTION (56.0f / 800.0f) #define MENU_DRAG_HANDLE_U_FRACTION 1.0f +// Every physical control a supported controller profile can bind, named +// independently of OpenXR objects. The first QS_RAW_CONCRETE_COUNT entries +// are real per-frame-readable inputs (g_raw_down[]/g_raw_prev[] cover exactly +// that range). QS_RAW_TRIGGER_SYMMETRIC/QS_RAW_SQUEEZE_SYMMETRIC mean "this +// hand's own trigger/squeeze" (see xr_input_raw_state_for_hand()) and are only +// valid for mouse_click/mouse_double_click; QS_RAW_NONE is unbound. +typedef enum { + QS_RAW_LEFT_TRIGGER, + QS_RAW_RIGHT_TRIGGER, + QS_RAW_LEFT_SQUEEZE, + QS_RAW_RIGHT_SQUEEZE, + QS_RAW_LEFT_X, + QS_RAW_LEFT_Y, + QS_RAW_RIGHT_A, + QS_RAW_RIGHT_B, + QS_RAW_LEFT_THUMBSTICK_CLICK, + QS_RAW_RIGHT_THUMBSTICK_CLICK, + QS_RAW_LEFT_THUMBSTICK_UP, + QS_RAW_LEFT_THUMBSTICK_DOWN, + QS_RAW_LEFT_THUMBSTICK_LEFT, + QS_RAW_LEFT_THUMBSTICK_RIGHT, + QS_RAW_RIGHT_THUMBSTICK_UP, + QS_RAW_RIGHT_THUMBSTICK_DOWN, + QS_RAW_RIGHT_THUMBSTICK_LEFT, + QS_RAW_RIGHT_THUMBSTICK_RIGHT, + QS_RAW_CONCRETE_COUNT, + QS_RAW_TRIGGER_SYMMETRIC = QS_RAW_CONCRETE_COUNT, + QS_RAW_SQUEEZE_SYMMETRIC, + QS_RAW_NONE, + QS_RAW_COUNT +} QsRawInput; + +// Index-aligned with QsRawInput: the strings used in questshock.config.json +// and reported by capture (which only ever observes the concrete inputs). +static const char *kRawInputNames[QS_RAW_COUNT] = { + "left_trigger", "right_trigger", "left_squeeze", "right_squeeze", "left_x", "left_y", + "right_a", "right_b", "left_thumbstick_click", "right_thumbstick_click", + "left_thumbstick_up", "left_thumbstick_down", "left_thumbstick_left", "left_thumbstick_right", + "right_thumbstick_up", "right_thumbstick_down", "right_thumbstick_left", + "right_thumbstick_right", "trigger", "squeeze", "none", +}; + +// Remappable game actions: movement, click and jump, the engine's own +// keyboard hotkeys (Prefs.c's HotKeyLookup[]), and fire. The hotkey actions +// default to QS_RAW_NONE (unbound). menu_toggle is not an action; it is fixed +// (see xr_input_sync_and_draw()). +// +// HotKeyLookup[] entries not exposed: +// - cheat_*: debug cheats. +// - mac_help (disabled) and audiolog_cancel (AUDIOLOGS is not defined). +// - toggle_opengl: the same toggle as the Video Options renderer buttons. +// - the 10 numeric hw_hotkey_callback entries: they are registered as bare +// ASCII literals without KB_FLAG_DOWN, but kb_cook() always ORs that flag +// into a real keydown, so a keypress never matches them. +// - keypad 0-9: a controller button per digit is not useful; the on-screen +// keyboard covers keypad entry. +typedef enum { + QS_ACTION_MOVE_FORWARD, + QS_ACTION_MOVE_BACK, + QS_ACTION_STRAFE_LEFT, + QS_ACTION_STRAFE_RIGHT, + QS_ACTION_TURN_LEFT, + QS_ACTION_TURN_RIGHT, + QS_ACTION_JUMP, + QS_ACTION_MOUSE_CLICK, + QS_ACTION_MOUSE_DOUBLE_CLICK, + QS_ACTION_STAND, + QS_ACTION_CROUCH, + QS_ACTION_PRONE, + QS_ACTION_TOGGLE_FREELOOK, + QS_ACTION_PAUSE, + QS_ACTION_FULL_VIEW, + QS_ACTION_NORMAL_VIEW, + QS_ACTION_MAP_VIEW, + QS_ACTION_SAVE_GAME, + QS_ACTION_LOAD_GAME, + QS_ACTION_SELECT_GRENADE, + QS_ACTION_ARM_GRENADE, + QS_ACTION_SELECT_DRUG, + QS_ACTION_USE_DRUG, + QS_ACTION_TOGGLE_ONLINE_HELP, + QS_ACTION_SHOW_HELP, + QS_ACTION_TOGGLE_MUSIC, + QS_ACTION_QUIT, + QS_ACTION_CYCLE_WEAPON_NEXT, + QS_ACTION_CYCLE_WEAPON_PREV, + QS_ACTION_CYCLE_DETAIL, + QS_ACTION_RELOAD_WEAPON_PRIMARY, + QS_ACTION_RELOAD_WEAPON_SECONDARY, + QS_ACTION_HUD_COLOR, + QS_ACTION_CLEAR_FULLSCREEN, + QS_ACTION_MFD_LEFT_1, + QS_ACTION_MFD_LEFT_2, + QS_ACTION_MFD_LEFT_3, + QS_ACTION_MFD_LEFT_4, + QS_ACTION_MFD_LEFT_5, + QS_ACTION_MFD_RIGHT_1, + QS_ACTION_MFD_RIGHT_2, + QS_ACTION_MFD_RIGHT_3, + QS_ACTION_MFD_RIGHT_4, + QS_ACTION_MFD_RIGHT_5, + QS_ACTION_FIRE_WEAPON, + QS_ACTION_COUNT +} QsControlAction; + +static const char *kActionNames[QS_ACTION_COUNT] = { + "move_forward", + "move_back", + "strafe_left", + "strafe_right", + "turn_left", + "turn_right", + "jump", + "mouse_click", + "mouse_double_click", + "stand", + "crouch", + "prone", + "toggle_freelook", + "pause", + "full_view", + "normal_view", + "map_view", + "save_game", + "load_game", + "select_grenade", + "arm_grenade", + "select_drug", + "use_drug", + "toggle_online_help", + "show_help", + "toggle_music", + "quit", + "cycle_weapon_next", + "cycle_weapon_prev", + "cycle_detail", + "reload_weapon_primary", + "reload_weapon_secondary", + "hud_color", + "clear_fullscreen", + "mfd_left_1", + "mfd_left_2", + "mfd_left_3", + "mfd_left_4", + "mfd_left_5", + "mfd_right_1", + "mfd_right_2", + "mfd_right_3", + "mfd_right_4", + "mfd_right_5", + "fire_weapon", +}; + +// Current bindings; xr_input.c owns them (qs_config.c applies parsed config via +// xr_input_set_action_binding() and reads them back for Save). Defaults: left +// stick moves (up/down) and strafes (left/right), its click jumps; right stick +// turns (left/right) and stands/goes prone (up/down), its click toggles free +// look; right A/B click/double-click; right trigger fires. Everything else is +// QS_RAW_NONE. +static QsRawInput s_binding[QS_ACTION_COUNT] = { + QS_RAW_LEFT_THUMBSTICK_UP, // move_forward + QS_RAW_LEFT_THUMBSTICK_DOWN, // move_back + QS_RAW_LEFT_THUMBSTICK_LEFT, // strafe_left + QS_RAW_LEFT_THUMBSTICK_RIGHT, // strafe_right + QS_RAW_RIGHT_THUMBSTICK_LEFT, // turn_left + QS_RAW_RIGHT_THUMBSTICK_RIGHT, // turn_right + QS_RAW_LEFT_THUMBSTICK_CLICK, // jump + QS_RAW_RIGHT_A, // mouse_click + QS_RAW_RIGHT_B, // mouse_double_click + QS_RAW_RIGHT_THUMBSTICK_UP, // stand + QS_RAW_NONE, // crouch + QS_RAW_RIGHT_THUMBSTICK_DOWN, // prone + QS_RAW_RIGHT_THUMBSTICK_CLICK, // toggle_freelook + QS_RAW_NONE, // pause + QS_RAW_NONE, // full_view + QS_RAW_NONE, // normal_view + QS_RAW_NONE, // map_view + QS_RAW_NONE, // save_game + QS_RAW_NONE, // load_game + QS_RAW_NONE, // select_grenade + QS_RAW_NONE, // arm_grenade + QS_RAW_NONE, // select_drug + QS_RAW_NONE, // use_drug + QS_RAW_NONE, // toggle_online_help + QS_RAW_NONE, // show_help + QS_RAW_NONE, // toggle_music + QS_RAW_NONE, // quit + QS_RAW_NONE, // cycle_weapon_next + QS_RAW_NONE, // cycle_weapon_prev + QS_RAW_NONE, // cycle_detail + QS_RAW_NONE, // reload_weapon_primary + QS_RAW_NONE, // reload_weapon_secondary + QS_RAW_NONE, // hud_color + QS_RAW_NONE, // clear_fullscreen + QS_RAW_NONE, // mfd_left_1 + QS_RAW_NONE, // mfd_left_2 + QS_RAW_NONE, // mfd_left_3 + QS_RAW_NONE, // mfd_left_4 + QS_RAW_NONE, // mfd_left_5 + QS_RAW_NONE, // mfd_right_1 + QS_RAW_NONE, // mfd_right_2 + QS_RAW_NONE, // mfd_right_3 + QS_RAW_NONE, // mfd_right_4 + QS_RAW_NONE, // mfd_right_5 + QS_RAW_RIGHT_TRIGGER, // fire_weapon +}; + +// One entry per key-injecting action. A plain, unmodified, printable key +// (needsTextInput, textChar) is sent as SDL_TEXTINPUT plus SDL_KEYDOWN/UP: +// sdl_events.c only registers such a key from its TEXTINPUT event. Every other +// entry (modifier held, or a control-range/F-key) is plain SDL_KEYDOWN/UP, with +// mod carrying the KMOD_ALT/CTRL/SHIFT the hotkey needs (see Prefs.c's +// HotKeyLookup[] DOWN()/SHIFT()/CTRL()/ALT() macros). +typedef struct { + QsControlAction action; + SDL_Scancode scancode; + SDL_Keycode keycode; + Uint16 mod; + bool needsTextInput; + char textChar; // only meaningful when needsTextInput is true +} QsKeyAction; + +static const QsKeyAction kKeyActions[] = { + {QS_ACTION_MOVE_FORWARD, SDL_SCANCODE_UP, SDLK_UP, KMOD_NONE, false, 0}, + {QS_ACTION_MOVE_BACK, SDL_SCANCODE_DOWN, SDLK_DOWN, KMOD_NONE, false, 0}, + {QS_ACTION_STRAFE_LEFT, SDL_SCANCODE_LEFT, SDLK_LEFT, KMOD_ALT, false, 0}, + {QS_ACTION_STRAFE_RIGHT, SDL_SCANCODE_RIGHT, SDLK_RIGHT, KMOD_ALT, false, 0}, + {QS_ACTION_TURN_LEFT, SDL_SCANCODE_LEFT, SDLK_LEFT, KMOD_NONE, false, 0}, + {QS_ACTION_TURN_RIGHT, SDL_SCANCODE_RIGHT, SDLK_RIGHT, KMOD_NONE, false, 0}, + {QS_ACTION_JUMP, SDL_SCANCODE_SPACE, SDLK_SPACE, KMOD_NONE, true, ' '}, + // "stand"/"crouch"/"prone"/"toggle_freelook"/"pause" - DOWN('t'/'g'/'b'/'f'/'p'). + {QS_ACTION_STAND, SDL_SCANCODE_T, SDLK_t, KMOD_NONE, true, 't'}, + {QS_ACTION_CROUCH, SDL_SCANCODE_G, SDLK_g, KMOD_NONE, true, 'g'}, + {QS_ACTION_PRONE, SDL_SCANCODE_B, SDLK_b, KMOD_NONE, true, 'b'}, + {QS_ACTION_TOGGLE_FREELOOK, SDL_SCANCODE_F, SDLK_f, KMOD_NONE, true, 'f'}, + {QS_ACTION_PAUSE, SDL_SCANCODE_P, SDLK_p, KMOD_NONE, true, 'p'}, + // "full_view"/"normal_view"/"map_view" - CTRL('f'/'d'/'a') - jump + // straight to the fullscreen/game/automap loop mode. + {QS_ACTION_FULL_VIEW, SDL_SCANCODE_F, SDLK_f, KMOD_CTRL, false, 0}, + {QS_ACTION_NORMAL_VIEW, SDL_SCANCODE_D, SDLK_d, KMOD_CTRL, false, 0}, + {QS_ACTION_MAP_VIEW, SDL_SCANCODE_A, SDLK_a, KMOD_CTRL, false, 0}, + // "save_game"/"load_game" - CTRL('s'/'l'). + {QS_ACTION_SAVE_GAME, SDL_SCANCODE_S, SDLK_s, KMOD_CTRL, false, 0}, + {QS_ACTION_LOAD_GAME, SDL_SCANCODE_L, SDLK_l, KMOD_CTRL, false, 0}, + // "select_grenade"/"select_drug" - CTRL('\'' / ';'). + {QS_ACTION_SELECT_GRENADE, SDL_SCANCODE_APOSTROPHE, SDLK_QUOTE, KMOD_CTRL, false, 0}, + // "arm_grenade"/"use_drug" - ALT('\'' / ';'). + {QS_ACTION_ARM_GRENADE, SDL_SCANCODE_APOSTROPHE, SDLK_QUOTE, KMOD_ALT, false, 0}, + {QS_ACTION_SELECT_DRUG, SDL_SCANCODE_SEMICOLON, SDLK_SEMICOLON, KMOD_CTRL, false, 0}, + {QS_ACTION_USE_DRUG, SDL_SCANCODE_SEMICOLON, SDLK_SEMICOLON, KMOD_ALT, false, 0}, + // "toggle_olh"/"showhelp" - CTRL('h') / ALT('o'). + {QS_ACTION_TOGGLE_ONLINE_HELP, SDL_SCANCODE_H, SDLK_h, KMOD_CTRL, false, 0}, + {QS_ACTION_SHOW_HELP, SDL_SCANCODE_O, SDLK_o, KMOD_ALT, false, 0}, + // "toggle_music"/"quit" - CTRL('m'/'q'). + {QS_ACTION_TOGGLE_MUSIC, SDL_SCANCODE_M, SDLK_m, KMOD_CTRL, false, 0}, + {QS_ACTION_QUIT, SDL_SCANCODE_Q, SDLK_q, KMOD_CTRL, false, 0}, + // "cycle_weapons 1"/"cycle_weapons -1" - Tab / Shift+Tab. + {QS_ACTION_CYCLE_WEAPON_NEXT, SDL_SCANCODE_TAB, SDLK_TAB, KMOD_NONE, false, 0}, + {QS_ACTION_CYCLE_WEAPON_PREV, SDL_SCANCODE_TAB, SDLK_TAB, KMOD_SHIFT, false, 0}, + // "cycle_detail" - CTRL('1'). + {QS_ACTION_CYCLE_DETAIL, SDL_SCANCODE_1, SDLK_1, KMOD_CTRL, false, 0}, + // "reload_weapon 1"/"reload_weapon 0" - CTRL(Backspace) / ALT(Backspace). + {QS_ACTION_RELOAD_WEAPON_PRIMARY, SDL_SCANCODE_BACKSPACE, SDLK_BACKSPACE, KMOD_CTRL, false, 0}, + {QS_ACTION_RELOAD_WEAPON_SECONDARY, SDL_SCANCODE_BACKSPACE, SDLK_BACKSPACE, KMOD_ALT, false, 0}, + // "hud_color" - ALT('h'). + {QS_ACTION_HUD_COLOR, SDL_SCANCODE_H, SDLK_h, KMOD_ALT, false, 0}, + // "clear_fullscreen" - DOWN(Backspace), no modifier. + {QS_ACTION_CLEAR_FULLSCREEN, SDL_SCANCODE_BACKSPACE, SDLK_BACKSPACE, KMOD_NONE, false, 0}, + // "mfd left/right N" - F1-F5 / F6-F10. + {QS_ACTION_MFD_LEFT_1, SDL_SCANCODE_F1, SDLK_F1, KMOD_NONE, false, 0}, + {QS_ACTION_MFD_LEFT_2, SDL_SCANCODE_F2, SDLK_F2, KMOD_NONE, false, 0}, + {QS_ACTION_MFD_LEFT_3, SDL_SCANCODE_F3, SDLK_F3, KMOD_NONE, false, 0}, + {QS_ACTION_MFD_LEFT_4, SDL_SCANCODE_F4, SDLK_F4, KMOD_NONE, false, 0}, + {QS_ACTION_MFD_LEFT_5, SDL_SCANCODE_F5, SDLK_F5, KMOD_NONE, false, 0}, + {QS_ACTION_MFD_RIGHT_1, SDL_SCANCODE_F6, SDLK_F6, KMOD_NONE, false, 0}, + {QS_ACTION_MFD_RIGHT_2, SDL_SCANCODE_F7, SDLK_F7, KMOD_NONE, false, 0}, + {QS_ACTION_MFD_RIGHT_3, SDL_SCANCODE_F8, SDLK_F8, KMOD_NONE, false, 0}, + {QS_ACTION_MFD_RIGHT_4, SDL_SCANCODE_F9, SDLK_F9, KMOD_NONE, false, 0}, + {QS_ACTION_MFD_RIGHT_5, SDL_SCANCODE_F10, SDLK_F10, KMOD_NONE, false, 0}, + // "fire_weapon" - the engine's FireKeys default (Prefs.c; input.c's + // view3d_key_handler()): Enter fires the readied weapon at the cursor and + // re-arms on release. Clicking in the 3D view (mouse_click) also fires. + {QS_ACTION_FIRE_WEAPON, SDL_SCANCODE_RETURN, SDLK_RETURN, KMOD_NONE, false, 0}, +}; +#define QS_NUM_KEY_ACTIONS (sizeof(kKeyActions) / sizeof(kKeyActions[0])) + static XrInstance g_instance = XR_NULL_HANDLE; static XrSession g_session = XR_NULL_HANDLE; static XrActionSet g_action_set = XR_NULL_HANDLE; static XrAction g_aim_pose_action = XR_NULL_HANDLE; +// Fixed for menu/keyboard-overlay clicks (see xr_input_sync_and_draw()); also +// backs QS_RAW_{LEFT,RIGHT}_TRIGGER's per-frame state. static XrAction g_select_click_action = XR_NULL_HANDLE; static XrAction g_menu_toggle_action = XR_NULL_HANDLE; -// WORKAROUND - the on-screen keyboard overlay is currently unusable, -// which blocks dismissing the "press Space to continue" tutorial screen -// shown right after starting a new game. This lets the right controller's -// A button send Space straight into the engine, bypassing the broken -// keyboard entirely. Remove once the keyboard overlay works. -static XrAction g_space_key_action = XR_NULL_HANDLE; -// Right controller's B button -> forward movement (Up arrow, one of the -// engine's default M_FORWARD keybinds per MacSrc/Prefs.c). Unlike Space, -// Up arrow is non-printable so a plain SDL_KEYDOWN/KEYUP is enough - no -// SDL_TEXTINPUT needed (see the space_key injection below for why that -// matters). -static XrAction g_forward_key_action = XR_NULL_HANDLE; +// Right controller's A button / left's X (Vive has neither); see +// QS_RAW_RIGHT_A/QS_RAW_LEFT_X. +static XrAction g_a_click_action = XR_NULL_HANDLE; +static XrAction g_b_click_action = XR_NULL_HANDLE; +static XrAction g_x_click_action = XR_NULL_HANDLE; +static XrAction g_y_click_action = XR_NULL_HANDLE; +static XrAction g_squeeze_action = XR_NULL_HANDLE; +static XrAction g_thumbstick_click_action = XR_NULL_HANDLE; // Player locomotion while the menu is open (see xr_session.h's // xr_session_set_player_transform()) - left stick snap-turns, right stick // smooth-moves, both read from the same Vector2f action via subactionPath. +// Also the backing action for QS_RAW_*_THUMBSTICK_{UP,DOWN,LEFT,RIGHT}'s +// digitization (see g_stick_digital below) - bound to the Vive profile's +// trackpad component instead of a thumbstick, since it's the same "2D +// directional input" shape either way. static XrAction g_thumbstick_action = XR_NULL_HANDLE; static XrPath g_hand_path[2]; static XrSpace g_aim_space[2] = {XR_NULL_HANDLE, XR_NULL_HANDLE}; @@ -82,9 +371,55 @@ static XrSpace g_aim_space[2] = {XR_NULL_HANDLE, XR_NULL_HANDLE}; // frame" independently, since the menu and keyboard overlays can now both // be visible at once. static bool g_prev_select[2] = {false, false}; +static bool g_prev_double_click[2] = {false, false}; static bool g_prev_menu = false; -static bool g_prev_space_key = false; -static bool g_prev_forward_key = false; +// One prior-down flag per kKeyActions[] entry. +static bool g_key_action_prev[QS_NUM_KEY_ACTIONS]; + +// Per-frame state for every raw input QS_RAW_CONCRETE_COUNT covers - +// computed once per frame (xr_input_update_raw_states(), unconditionally, +// regardless of menuVisible) and consumed by capture mode, the key-action +// dispatch loop, and mouse_click/mouse_double_click's resolution. Indices +// QS_RAW_TRIGGER_SYMMETRIC/QS_RAW_SQUEEZE_SYMMETRIC/QS_RAW_NONE are never +// written here - see xr_input_raw_state_for_hand() for how those resolve +// instead. +static bool g_raw_down[QS_RAW_COUNT]; +static bool g_raw_prev[QS_RAW_COUNT]; + +// Per-hand thumbstick digitization hysteresis (see xr_input_digitize_stick()), +// independent per direction so a diagonal deflection can hold two directions; +// separate from g_turn_ready below, which belongs to the menu's snap turn. +typedef struct { + bool up, down, left, right; +} XrStickDigital; +static XrStickDigital g_stick_digital[2]; + +// Rebind capture state (see xr_input_capture_begin()/qs_config.c's own +// comment on the cross-thread request that calls it) - g_capture_action is +// only meaningful while g_capture_active is true. +static bool g_capture_active = false; +// Last menuVisible value reported to qs_config_notify_menu_visibility() - +// that's how a change (however the menu got shown/hidden) is noticed. +static bool g_menu_was_visible = false; +// Set from MenuOverlay.java's UI thread (a single-byte flag - no need for +// the cross-thread event dispatch the multi-step requests use) whenever the +// Controller Options tab becomes/stops being the menu's current tab. While +// it's true, the menu's own thumbstick locomotion (snap turn/smooth move, +// see xr_input_sync_and_draw()) is disabled: pressing sticks there to test +// or rebind a control shouldn't also shift the player away from the +// virtual screen. +static volatile bool g_controller_options_tab_open = false; +static QsControlAction g_capture_action; +static Uint32 g_capture_start_ticks = 0; +#define QS_CAPTURE_TIMEOUT_MS 8000 + +// Refreshed once per frame (see xr_input_sync_and_draw()) via +// xrGetCurrentInteractionProfile()/xrPathToString() - which controller +// profile (Touch/Vive/generic) is actually bound right now, read by +// MenuOverlay.java's Controller Options tab to pick a matching profile +// file from controller_profiles/ (see CONFIG.md). Empty string until the +// first successful query. +static char g_active_profile_path[256] = ""; // Snap-turn hysteresis - true when the left stick has returned near center // since the last turn it triggered, i.e. it's "armed" to trigger another // one; false from the moment a turn triggers until the stick re-centers, @@ -240,6 +575,377 @@ static void mat3_to_quat(const float x[3], const float y[3], const float z[3], X } } +// =========================================================================== +// Remappable controls: raw-input naming, per-frame state, capture mode +// =========================================================================== + +// Bounds-checked - always returns a valid string ("none" for an +// out-of-range value, which shouldn't happen but is cheap to guard). +static const char *xr_input_raw_input_name(QsRawInput raw) { + if (raw < 0 || raw >= QS_RAW_COUNT) + return kRawInputNames[QS_RAW_NONE]; + return kRawInputNames[raw]; +} + +// Returns false (leaves *out untouched) if `name` isn't one of +// kRawInputNames[]'s exact strings - the caller (qs_config.c's +// qs_apply_player_controls()) treats that as an invalid config value, same +// WARN+skip handling as an invalid renderer/resolution string. +static bool xr_input_raw_input_from_name(const char *name, QsRawInput *out) { + for (int i = 0; i < QS_RAW_COUNT; i++) { + if (strcmp(name, kRawInputNames[i]) == 0) { + *out = (QsRawInput)i; + return true; + } + } + return false; +} + +// LEFT/RIGHT for any of the first QS_RAW_CONCRETE_COUNT values (every one +// of those names is already hand-qualified) - never called for the +// symmetric/none pseudo-values, which have no single hand. +static int xr_input_raw_input_hand(QsRawInput raw) { + switch (raw) { + case QS_RAW_LEFT_TRIGGER: + case QS_RAW_LEFT_SQUEEZE: + case QS_RAW_LEFT_X: + case QS_RAW_LEFT_Y: + case QS_RAW_LEFT_THUMBSTICK_CLICK: + case QS_RAW_LEFT_THUMBSTICK_UP: + case QS_RAW_LEFT_THUMBSTICK_DOWN: + case QS_RAW_LEFT_THUMBSTICK_LEFT: + case QS_RAW_LEFT_THUMBSTICK_RIGHT: + return LEFT; + default: + return RIGHT; + } +} + +// Resolves what a bound raw input means for one specific hand's ray this +// frame - the one place symmetric bindings ("trigger"/"squeeze") and +// per-hand exclusivity (a concrete binding only ever fires for its own +// hand) both get handled, shared by mouse_click/mouse_double_click's +// resolution in the per-hand loop below. +static bool xr_input_raw_state_for_hand(QsRawInput raw, int hand) { + if (raw == QS_RAW_NONE) + return false; + if (raw == QS_RAW_TRIGGER_SYMMETRIC) + return g_raw_down[hand == LEFT ? QS_RAW_LEFT_TRIGGER : QS_RAW_RIGHT_TRIGGER]; + if (raw == QS_RAW_SQUEEZE_SYMMETRIC) + return g_raw_down[hand == LEFT ? QS_RAW_LEFT_SQUEEZE : QS_RAW_RIGHT_SQUEEZE]; + if (xr_input_raw_input_hand(raw) != hand) + return false; + return g_raw_down[raw]; +} + +// True while `action` shouldn't fire its own effect this frame because +// it's the very action currently being captured (see xr_input_capture_begin()) +// - e.g. capturing "jump" shouldn't also make the avatar jump. +static bool xr_input_action_suppressed_by_capture(QsControlAction action) { + return g_capture_active && g_capture_action == action; +} + +static bool xr_input_bool_state(XrAction action, int hand) { + if (action == XR_NULL_HANDLE) + return false; + XrActionStateGetInfo info = {XR_TYPE_ACTION_STATE_GET_INFO}; + info.action = action; + info.subactionPath = g_hand_path[hand]; + XrActionStateBoolean state = {XR_TYPE_ACTION_STATE_BOOLEAN}; + xrGetActionStateBoolean(g_session, &info, &state); + return state.isActive && state.currentState; +} + +// Digitizes one hand's thumbstick/trackpad Vector2f into 4 independent +// directions with hysteresis (engage past kEngage, release back below +// kRelease) - same engage/release reasoning as the existing menu-only +// snap-turn's own hysteresis latch (g_turn_ready), just applied +// per-direction here so a diagonal deflection can hold two directions at +// once. y>0 is "away from the player" (forward), matching the existing +// smooth-move code's own convention for the same Vector2f action. +static void xr_input_digitize_stick(XrStickDigital *state, bool active, float x, float y) { + const float kEngage = 0.5f; + const float kRelease = 0.3f; + if (!active) { + state->up = state->down = state->left = state->right = false; + return; + } + if (state->up) { + if (y < kRelease) + state->up = false; + } else if (y > kEngage) { + state->up = true; + } + if (state->down) { + if (-y < kRelease) + state->down = false; + } else if (-y > kEngage) { + state->down = true; + } + if (state->left) { + if (-x < kRelease) + state->left = false; + } else if (-x > kEngage) { + state->left = true; + } + if (state->right) { + if (x < kRelease) + state->right = false; + } else if (x > kEngage) { + state->right = true; + } +} + +// Fills g_raw_down[] for every one of the first QS_RAW_CONCRETE_COUNT raw +// inputs - called once per frame, unconditionally (regardless of +// menuVisible), before capture mode's scan and the key-action dispatch +// loop both read it. g_select_click_action doubles as the source for +// QS_RAW_*_TRIGGER (see its own declaration comment) - already synced by +// xrSyncActions() before this runs, same as every other action here. +static void xr_input_update_raw_states(void) { + g_raw_down[QS_RAW_LEFT_TRIGGER] = xr_input_bool_state(g_select_click_action, LEFT); + g_raw_down[QS_RAW_RIGHT_TRIGGER] = xr_input_bool_state(g_select_click_action, RIGHT); + g_raw_down[QS_RAW_LEFT_SQUEEZE] = xr_input_bool_state(g_squeeze_action, LEFT); + g_raw_down[QS_RAW_RIGHT_SQUEEZE] = xr_input_bool_state(g_squeeze_action, RIGHT); + g_raw_down[QS_RAW_LEFT_X] = xr_input_bool_state(g_x_click_action, LEFT); + g_raw_down[QS_RAW_LEFT_Y] = xr_input_bool_state(g_y_click_action, LEFT); + g_raw_down[QS_RAW_RIGHT_A] = xr_input_bool_state(g_a_click_action, RIGHT); + g_raw_down[QS_RAW_RIGHT_B] = xr_input_bool_state(g_b_click_action, RIGHT); + g_raw_down[QS_RAW_LEFT_THUMBSTICK_CLICK] = xr_input_bool_state(g_thumbstick_click_action, LEFT); + g_raw_down[QS_RAW_RIGHT_THUMBSTICK_CLICK] = + xr_input_bool_state(g_thumbstick_click_action, RIGHT); + + for (int hand = 0; hand < 2; hand++) { + XrActionStateGetInfo info = {XR_TYPE_ACTION_STATE_GET_INFO}; + info.action = g_thumbstick_action; + info.subactionPath = g_hand_path[hand]; + XrActionStateVector2f state = {XR_TYPE_ACTION_STATE_VECTOR2F}; + xrGetActionStateVector2f(g_session, &info, &state); + xr_input_digitize_stick(&g_stick_digital[hand], state.isActive, state.currentState.x, + state.currentState.y); + } + g_raw_down[QS_RAW_LEFT_THUMBSTICK_UP] = g_stick_digital[LEFT].up; + g_raw_down[QS_RAW_LEFT_THUMBSTICK_DOWN] = g_stick_digital[LEFT].down; + g_raw_down[QS_RAW_LEFT_THUMBSTICK_LEFT] = g_stick_digital[LEFT].left; + g_raw_down[QS_RAW_LEFT_THUMBSTICK_RIGHT] = g_stick_digital[LEFT].right; + g_raw_down[QS_RAW_RIGHT_THUMBSTICK_UP] = g_stick_digital[RIGHT].up; + g_raw_down[QS_RAW_RIGHT_THUMBSTICK_DOWN] = g_stick_digital[RIGHT].down; + g_raw_down[QS_RAW_RIGHT_THUMBSTICK_LEFT] = g_stick_digital[RIGHT].left; + g_raw_down[QS_RAW_RIGHT_THUMBSTICK_RIGHT] = g_stick_digital[RIGHT].right; +} + +// True for the 8 thumbstick-direction identifiers specifically - see +// xr_input_sync_and_draw()'s own comment on why a movement action bound to +// one of these is suppressed while the menu is open (the same physical +// stick already drives VR-position calibration in that state). +static bool xr_input_raw_is_thumbstick_direction(QsRawInput raw) { + return raw >= QS_RAW_LEFT_THUMBSTICK_UP && raw <= QS_RAW_RIGHT_THUMBSTICK_RIGHT; +} + +void xr_input_capture_begin(int action_index) { + if (action_index < 0 || action_index >= QS_ACTION_COUNT) + return; + g_capture_active = true; + g_capture_action = (QsControlAction)action_index; + g_capture_start_ticks = SDL_GetTicks(); +} + +void xr_input_capture_cancel(void) { g_capture_active = false; } + +void xr_input_reset_player_controls(void) { + // Mirrors s_binding[]'s own static initializer exactly (see its own + // comment) - the hotkey actions all default to unbound. + static const QsRawInput kDefaults[QS_ACTION_COUNT] = { + QS_RAW_LEFT_THUMBSTICK_UP, // move_forward + QS_RAW_LEFT_THUMBSTICK_DOWN, // move_back + QS_RAW_LEFT_THUMBSTICK_LEFT, // strafe_left + QS_RAW_LEFT_THUMBSTICK_RIGHT, // strafe_right + QS_RAW_RIGHT_THUMBSTICK_LEFT, // turn_left + QS_RAW_RIGHT_THUMBSTICK_RIGHT, // turn_right + QS_RAW_LEFT_THUMBSTICK_CLICK, // jump + QS_RAW_RIGHT_A, // mouse_click + QS_RAW_RIGHT_B, // mouse_double_click + QS_RAW_RIGHT_THUMBSTICK_UP, // stand + QS_RAW_NONE, // crouch + QS_RAW_RIGHT_THUMBSTICK_DOWN, // prone + QS_RAW_RIGHT_THUMBSTICK_CLICK, // toggle_freelook + QS_RAW_NONE, // pause + QS_RAW_NONE, // full_view + QS_RAW_NONE, // normal_view + QS_RAW_NONE, // map_view + QS_RAW_NONE, // save_game + QS_RAW_NONE, // load_game + QS_RAW_NONE, // select_grenade + QS_RAW_NONE, // arm_grenade + QS_RAW_NONE, // select_drug + QS_RAW_NONE, // use_drug + QS_RAW_NONE, // toggle_online_help + QS_RAW_NONE, // show_help + QS_RAW_NONE, // toggle_music + QS_RAW_NONE, // quit + QS_RAW_NONE, // cycle_weapon_next + QS_RAW_NONE, // cycle_weapon_prev + QS_RAW_NONE, // cycle_detail + QS_RAW_NONE, // reload_weapon_primary + QS_RAW_NONE, // reload_weapon_secondary + QS_RAW_NONE, // hud_color + QS_RAW_NONE, // clear_fullscreen + QS_RAW_NONE, // mfd_left_1 + QS_RAW_NONE, // mfd_left_2 + QS_RAW_NONE, // mfd_left_3 + QS_RAW_NONE, // mfd_left_4 + QS_RAW_NONE, // mfd_left_5 + QS_RAW_NONE, // mfd_right_1 + QS_RAW_NONE, // mfd_right_2 + QS_RAW_NONE, // mfd_right_3 + QS_RAW_NONE, // mfd_right_4 + QS_RAW_NONE, // mfd_right_5 + QS_RAW_RIGHT_TRIGGER, // fire_weapon + }; + memcpy(s_binding, kDefaults, sizeof(s_binding)); + g_capture_active = false; +} + +int xr_input_get_action_binding(int action_index) { + if (action_index < 0 || action_index >= QS_ACTION_COUNT) + return QS_RAW_NONE; + return (int)s_binding[action_index]; +} + +bool xr_input_set_action_binding(int action_index, const char *raw_name) { + if (action_index < 0 || action_index >= QS_ACTION_COUNT) + return false; + QsRawInput raw; + if (!xr_input_raw_input_from_name(raw_name, &raw)) + return false; + // Only mouse_click/mouse_double_click may use the symmetric forms - + // see QsRawInput's own comment. + if ((raw == QS_RAW_TRIGGER_SYMMETRIC || raw == QS_RAW_SQUEEZE_SYMMETRIC) && + action_index != QS_ACTION_MOUSE_CLICK && action_index != QS_ACTION_MOUSE_DOUBLE_CLICK) + return false; + s_binding[action_index] = raw; + return true; +} + +const char *xr_input_get_action_binding_name(int action_index) { + return xr_input_raw_input_name((QsRawInput)xr_input_get_action_binding(action_index)); +} + +// True if an action holding `binding` would still fire from a press of +// `matched`: the same concrete input, or a symmetric "trigger"/"squeeze" +// binding when `matched` is that hand's trigger/squeeze. +static bool xr_input_binding_conflicts(QsRawInput binding, QsRawInput matched) { + if (binding == matched) + return true; + if (binding == QS_RAW_TRIGGER_SYMMETRIC) + return matched == QS_RAW_LEFT_TRIGGER || matched == QS_RAW_RIGHT_TRIGGER; + if (binding == QS_RAW_SQUEEZE_SYMMETRIC) + return matched == QS_RAW_LEFT_SQUEEZE || matched == QS_RAW_RIGHT_SQUEEZE; + return false; +} + +// Scans for a fresh down-edge among the QS_RAW_CONCRETE_COUNT raw inputs +// while a capture is active - called once per frame, right after +// xr_input_update_raw_states() and before the key-action dispatch loop, so +// a same-frame press isn't also consumed as this action's normal effect +// (see xr_input_action_suppressed_by_capture() for the other half of that +// guarantee). On a match, enforces exclusivity (at most one action may +// respond to a given raw input - see xr_input_binding_conflicts() above) +// by clearing any other action that would otherwise still fire from it, +// then reports both results in one call. +static void xr_input_process_capture(void) { + if (!g_capture_active) + return; + if (SDL_GetTicks() - g_capture_start_ticks > QS_CAPTURE_TIMEOUT_MS) { + g_capture_active = false; + qs_config_notify_control_capture_result((int)g_capture_action, NULL, -1, NULL); + return; + } + for (int i = 0; i < QS_RAW_CONCRETE_COUNT; i++) { + if (!(g_raw_down[i] && !g_raw_prev[i])) + continue; + QsRawInput matched = (QsRawInput)i; + int displacedAction = -1; + for (int a = 0; a < QS_ACTION_COUNT; a++) { + if (a != (int)g_capture_action && xr_input_binding_conflicts(s_binding[a], matched)) { + s_binding[a] = QS_RAW_NONE; + displacedAction = a; + break; // exclusivity guarantees at most one prior holder + } + } + s_binding[g_capture_action] = matched; + g_capture_active = false; + qs_config_notify_control_capture_result( + (int)g_capture_action, xr_input_raw_input_name(matched), displacedAction, + displacedAction >= 0 ? xr_input_raw_input_name(QS_RAW_NONE) : NULL); + return; + } +} + +int xr_input_action_count(void) { return QS_ACTION_COUNT; } + +// Bounds-checked, same convention as xr_input_raw_input_name() - "" for an +// out-of-range index. +const char *xr_input_action_name(int action_index) { + if (action_index < 0 || action_index >= QS_ACTION_COUNT) + return ""; + return kActionNames[action_index]; +} + +bool xr_input_action_index_from_name(const char *name, int *out_index) { + for (int i = 0; i < QS_ACTION_COUNT; i++) { + if (strcmp(name, kActionNames[i]) == 0) { + *out_index = i; + return true; + } + } + return false; +} + +const char *xr_input_get_active_interaction_profile_path(void) { return g_active_profile_path; } + +void xr_input_set_controller_options_tab_open(bool open) { g_controller_options_tab_open = open; } + +// Shared boilerplate for every boolean action below - aim_pose (Pose) and +// thumbstick (Vector2f) stay special-cased since they're a different +// XrActionType. +static bool xr_input_create_bool_action(const char *name, const char *localized, + uint32_t subaction_count, const XrPath *subaction_paths, + XrAction *out) { + XrActionCreateInfo info = {XR_TYPE_ACTION_CREATE_INFO}; + strncpy(info.actionName, name, XR_MAX_ACTION_NAME_SIZE - 1); + strncpy(info.localizedActionName, localized, XR_MAX_LOCALIZED_ACTION_NAME_SIZE - 1); + info.actionType = XR_ACTION_TYPE_BOOLEAN_INPUT; + info.countSubactionPaths = subaction_count; + info.subactionPaths = subaction_paths; + char what[64]; + snprintf(what, sizeof(what), "xrCreateAction(%s)", name); + return xr_check(xrCreateAction(g_action_set, &info, out), what); +} + +// Suggests one interaction profile's bindings and, on failure, reports the +// fatal-startup error (see qs_config_report_fatal_input_error()'s own +// comment) - a wrong/nonexistent path anywhere in `bindings` fails this +// entire call, not just that one entry, so this is the one place that +// matters for every profile. +static bool xr_input_suggest_bindings(XrInstance instance, const char *profile_path, + const char *profile_display_name, + const XrActionSuggestedBinding *bindings, uint32_t count) { + XrPath profilePath; + xrStringToPath(instance, profile_path, &profilePath); + XrInteractionProfileSuggestedBinding suggested = {XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING}; + suggested.interactionProfile = profilePath; + suggested.countSuggestedBindings = count; + suggested.suggestedBindings = bindings; + char what[96]; + snprintf(what, sizeof(what), "xrSuggestInteractionProfileBindings(%s)", profile_display_name); + if (!xr_check(xrSuggestInteractionProfileBindings(instance, &suggested), what)) { + qs_config_report_fatal_input_error(profile_display_name); + return false; + } + return true; +} + bool xr_input_init(XrInstance instance, XrSession session, int64_t swapchain_format) { g_instance = instance; g_session = session; @@ -264,51 +970,33 @@ bool xr_input_init(XrInstance instance, XrSession session, int64_t swapchain_for "xrCreateAction(aim_pose)")) return false; - XrActionCreateInfo selectInfo = {XR_TYPE_ACTION_CREATE_INFO}; - strncpy(selectInfo.actionName, "select_click", XR_MAX_ACTION_NAME_SIZE - 1); - strncpy(selectInfo.localizedActionName, "Select", XR_MAX_LOCALIZED_ACTION_NAME_SIZE - 1); - selectInfo.actionType = XR_ACTION_TYPE_BOOLEAN_INPUT; - selectInfo.countSubactionPaths = 2; - selectInfo.subactionPaths = g_hand_path; - if (!xr_check(xrCreateAction(g_action_set, &selectInfo, &g_select_click_action), - "xrCreateAction(select_click)")) + if (!xr_input_create_bool_action("select_click", "Select", 2, g_hand_path, + &g_select_click_action)) return false; - - // The touch_controller profile's "menu" component (the three-line - // hamburger icon) only exists on the left controller - there's no - // right-hand equivalent. - XrActionCreateInfo menuInfo = {XR_TYPE_ACTION_CREATE_INFO}; - strncpy(menuInfo.actionName, "menu_toggle", XR_MAX_ACTION_NAME_SIZE - 1); - strncpy(menuInfo.localizedActionName, "Menu Toggle", XR_MAX_LOCALIZED_ACTION_NAME_SIZE - 1); - menuInfo.actionType = XR_ACTION_TYPE_BOOLEAN_INPUT; - menuInfo.countSubactionPaths = 1; - menuInfo.subactionPaths = &g_hand_path[LEFT]; - if (!xr_check(xrCreateAction(g_action_set, &menuInfo, &g_menu_toggle_action), - "xrCreateAction(menu_toggle)")) + // The touch_controller/vive_controller/simple_controller profiles' + // "menu" component only exists on the left controller (by hardware, on + // Touch) or is only ever suggested there anyway (Vive, simple - see + // this function's own per-profile binding arrays below) - there's no + // right-hand equivalent suggested for any of them. + if (!xr_input_create_bool_action("menu_toggle", "Menu Toggle", 1, &g_hand_path[LEFT], + &g_menu_toggle_action)) return false; - - // WORKAROUND - right controller's A button, see comment on - // g_space_key_action's declaration above. - XrActionCreateInfo spaceKeyInfo = {XR_TYPE_ACTION_CREATE_INFO}; - strncpy(spaceKeyInfo.actionName, "space_key", XR_MAX_ACTION_NAME_SIZE - 1); - strncpy(spaceKeyInfo.localizedActionName, "Space Key", XR_MAX_LOCALIZED_ACTION_NAME_SIZE - 1); - spaceKeyInfo.actionType = XR_ACTION_TYPE_BOOLEAN_INPUT; - spaceKeyInfo.countSubactionPaths = 1; - spaceKeyInfo.subactionPaths = &g_hand_path[RIGHT]; - if (!xr_check(xrCreateAction(g_action_set, &spaceKeyInfo, &g_space_key_action), - "xrCreateAction(space_key)")) + if (!xr_input_create_bool_action("a_click", "A Button", 1, &g_hand_path[RIGHT], + &g_a_click_action)) return false; - - // Right controller's B button, see comment on g_forward_key_action's - // declaration above. - XrActionCreateInfo forwardKeyInfo = {XR_TYPE_ACTION_CREATE_INFO}; - strncpy(forwardKeyInfo.actionName, "forward_key", XR_MAX_ACTION_NAME_SIZE - 1); - strncpy(forwardKeyInfo.localizedActionName, "Forward Key", XR_MAX_LOCALIZED_ACTION_NAME_SIZE - 1); - forwardKeyInfo.actionType = XR_ACTION_TYPE_BOOLEAN_INPUT; - forwardKeyInfo.countSubactionPaths = 1; - forwardKeyInfo.subactionPaths = &g_hand_path[RIGHT]; - if (!xr_check(xrCreateAction(g_action_set, &forwardKeyInfo, &g_forward_key_action), - "xrCreateAction(forward_key)")) + if (!xr_input_create_bool_action("b_click", "B Button", 1, &g_hand_path[RIGHT], + &g_b_click_action)) + return false; + if (!xr_input_create_bool_action("x_click", "X Button", 1, &g_hand_path[LEFT], + &g_x_click_action)) + return false; + if (!xr_input_create_bool_action("y_click", "Y Button", 1, &g_hand_path[LEFT], + &g_y_click_action)) + return false; + if (!xr_input_create_bool_action("squeeze", "Squeeze", 2, g_hand_path, &g_squeeze_action)) + return false; + if (!xr_input_create_bool_action("thumbstick_click", "Thumbstick Click", 2, g_hand_path, + &g_thumbstick_click_action)) return false; // Player locomotion, see g_thumbstick_action's own declaration comment. @@ -322,43 +1010,109 @@ bool xr_input_init(XrInstance instance, XrSession session, int64_t swapchain_for "xrCreateAction(thumbstick)")) return false; - XrPath profilePath; - xrStringToPath(instance, "/interaction_profiles/oculus/touch_controller", &profilePath); + // Every path the three suggested-bindings arrays below reference. One + // wrong or nonexistent path fails that profile's whole + // xrSuggestInteractionProfileBindings call (see + // xr_input_suggest_bindings()). + XrPath aimPoseLeft, aimPoseRight, menuClick; + xrStringToPath(instance, "/user/hand/left/input/aim/pose", &aimPoseLeft); + xrStringToPath(instance, "/user/hand/right/input/aim/pose", &aimPoseRight); + xrStringToPath(instance, "/user/hand/left/input/menu/click", &menuClick); // touch_controller has no .../trigger/click component, only the float // .../trigger/value - binding this boolean action straight to it // relies on OpenXR's standard action-type equivalence (runtimes // auto-convert float inputs to boolean via an internal threshold), the // same pattern Khronos' own hello_xr sample uses for its "select" - // action. Suggesting a binding to a component the profile doesn't - // have at all (like a nonexistent .../trigger/click) fails the entire - // xrSuggestInteractionProfileBindings call, not just that one entry. - XrPath aimPoseLeftPath, aimPoseRightPath, triggerLeftPath, triggerRightPath, menuClickPath, - aClickPath, bClickPath, thumbstickLeftPath, thumbstickRightPath; - xrStringToPath(instance, "/user/hand/left/input/aim/pose", &aimPoseLeftPath); - xrStringToPath(instance, "/user/hand/right/input/aim/pose", &aimPoseRightPath); - xrStringToPath(instance, "/user/hand/left/input/trigger/value", &triggerLeftPath); - xrStringToPath(instance, "/user/hand/right/input/trigger/value", &triggerRightPath); - xrStringToPath(instance, "/user/hand/left/input/menu/click", &menuClickPath); - xrStringToPath(instance, "/user/hand/right/input/a/click", &aClickPath); - xrStringToPath(instance, "/user/hand/right/input/b/click", &bClickPath); - xrStringToPath(instance, "/user/hand/left/input/thumbstick", &thumbstickLeftPath); - xrStringToPath(instance, "/user/hand/right/input/thumbstick", &thumbstickRightPath); - - XrActionSuggestedBinding bindings[] = { - {g_aim_pose_action, aimPoseLeftPath}, {g_aim_pose_action, aimPoseRightPath}, - {g_select_click_action, triggerLeftPath}, {g_select_click_action, triggerRightPath}, - {g_menu_toggle_action, menuClickPath}, - {g_space_key_action, aClickPath}, - {g_forward_key_action, bClickPath}, - {g_thumbstick_action, thumbstickLeftPath}, {g_thumbstick_action, thumbstickRightPath}, + // action. + XrPath touchTriggerLeft, touchTriggerRight, touchAClick, touchBClick, touchXClick, touchYClick, + touchSqueezeLeft, touchSqueezeRight, touchStickLeft, touchStickRight, touchStickClickLeft, + touchStickClickRight; + xrStringToPath(instance, "/user/hand/left/input/trigger/value", &touchTriggerLeft); + xrStringToPath(instance, "/user/hand/right/input/trigger/value", &touchTriggerRight); + xrStringToPath(instance, "/user/hand/right/input/a/click", &touchAClick); + xrStringToPath(instance, "/user/hand/right/input/b/click", &touchBClick); + xrStringToPath(instance, "/user/hand/left/input/x/click", &touchXClick); + xrStringToPath(instance, "/user/hand/left/input/y/click", &touchYClick); + xrStringToPath(instance, "/user/hand/left/input/squeeze/value", &touchSqueezeLeft); + xrStringToPath(instance, "/user/hand/right/input/squeeze/value", &touchSqueezeRight); + xrStringToPath(instance, "/user/hand/left/input/thumbstick", &touchStickLeft); + xrStringToPath(instance, "/user/hand/right/input/thumbstick", &touchStickRight); + xrStringToPath(instance, "/user/hand/left/input/thumbstick/click", &touchStickClickLeft); + xrStringToPath(instance, "/user/hand/right/input/thumbstick/click", &touchStickClickRight); + const XrActionSuggestedBinding touchBindings[] = { + {g_aim_pose_action, aimPoseLeft}, + {g_aim_pose_action, aimPoseRight}, + {g_select_click_action, touchTriggerLeft}, + {g_select_click_action, touchTriggerRight}, + {g_menu_toggle_action, menuClick}, + {g_a_click_action, touchAClick}, + {g_b_click_action, touchBClick}, + {g_x_click_action, touchXClick}, + {g_y_click_action, touchYClick}, + {g_squeeze_action, touchSqueezeLeft}, + {g_squeeze_action, touchSqueezeRight}, + {g_thumbstick_action, touchStickLeft}, + {g_thumbstick_action, touchStickRight}, + {g_thumbstick_click_action, touchStickClickLeft}, + {g_thumbstick_click_action, touchStickClickRight}, }; - XrInteractionProfileSuggestedBinding suggested = {XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING}; - suggested.interactionProfile = profilePath; - suggested.countSuggestedBindings = sizeof(bindings) / sizeof(bindings[0]); - suggested.suggestedBindings = bindings; - if (!xr_check(xrSuggestInteractionProfileBindings(instance, &suggested), - "xrSuggestInteractionProfileBindings")) + if (!xr_input_suggest_bindings(instance, "/interaction_profiles/oculus/touch_controller", + "Meta Quest Touch", touchBindings, + sizeof(touchBindings) / sizeof(touchBindings[0]))) + return false; + + // HTC Vive wand: trigger and squeeze are native booleans (squeeze has no + // analog value); the trackpad takes the thumbstick/thumbstick_click + // actions' place, so xr_input_update_raw_states()'s digitization applies + // unchanged. There is no A/B/X/Y, so those four actions are never bound + // under this profile. + XrPath viveTriggerLeft, viveTriggerRight, viveSqueezeLeft, viveSqueezeRight, viveTrackpadLeft, + viveTrackpadRight, viveTrackpadClickLeft, viveTrackpadClickRight; + xrStringToPath(instance, "/user/hand/left/input/trigger/click", &viveTriggerLeft); + xrStringToPath(instance, "/user/hand/right/input/trigger/click", &viveTriggerRight); + xrStringToPath(instance, "/user/hand/left/input/squeeze/click", &viveSqueezeLeft); + xrStringToPath(instance, "/user/hand/right/input/squeeze/click", &viveSqueezeRight); + xrStringToPath(instance, "/user/hand/left/input/trackpad", &viveTrackpadLeft); + xrStringToPath(instance, "/user/hand/right/input/trackpad", &viveTrackpadRight); + xrStringToPath(instance, "/user/hand/left/input/trackpad/click", &viveTrackpadClickLeft); + xrStringToPath(instance, "/user/hand/right/input/trackpad/click", &viveTrackpadClickRight); + const XrActionSuggestedBinding viveBindings[] = { + {g_aim_pose_action, aimPoseLeft}, + {g_aim_pose_action, aimPoseRight}, + {g_select_click_action, viveTriggerLeft}, + {g_select_click_action, viveTriggerRight}, + {g_menu_toggle_action, menuClick}, + {g_squeeze_action, viveSqueezeLeft}, + {g_squeeze_action, viveSqueezeRight}, + {g_thumbstick_action, viveTrackpadLeft}, + {g_thumbstick_action, viveTrackpadRight}, + {g_thumbstick_click_action, viveTrackpadClickLeft}, + {g_thumbstick_click_action, viveTrackpadClickRight}, + }; + if (!xr_input_suggest_bindings(instance, "/interaction_profiles/htc/vive_controller", + "HTC Vive", viveBindings, + sizeof(viveBindings) / sizeof(viveBindings[0]))) + return false; + + // khr/simple_controller - OpenXR's own minimal baseline profile, not a + // specific device: select+menu only, deliberately so (see CONFIG.md). + // No squeeze/thumbstick/A-B-X-Y bindings exist for it - every action + // other than select_click/menu_toggle/aim_pose is permanently inactive + // under this profile. + XrPath simpleSelectLeft, simpleSelectRight; + xrStringToPath(instance, "/user/hand/left/input/select/click", &simpleSelectLeft); + xrStringToPath(instance, "/user/hand/right/input/select/click", &simpleSelectRight); + const XrActionSuggestedBinding simpleBindings[] = { + {g_aim_pose_action, aimPoseLeft}, + {g_aim_pose_action, aimPoseRight}, + {g_select_click_action, simpleSelectLeft}, + {g_select_click_action, simpleSelectRight}, + {g_menu_toggle_action, menuClick}, + }; + if (!xr_input_suggest_bindings(instance, "/interaction_profiles/khr/simple_controller", + "Generic OpenXR Controller", simpleBindings, + sizeof(simpleBindings) / sizeof(simpleBindings[0]))) return false; for (int hand = 0; hand < 2; hand++) { @@ -392,7 +1146,9 @@ bool xr_input_init(XrInstance instance, XrSession session, int64_t swapchain_for } } - LOGI("XR: input action set ready (aim pose + trigger + menu-toggle + A=space + B=forward)"); + LOGI("XR: input action set ready (Touch/Vive/generic bindings suggested, %d remappable " + "actions)", + (int)QS_ACTION_COUNT); return true; } @@ -829,7 +1585,11 @@ static void xr_input_recenter_menu(XrOverlay *menuOverlay, XrSpace baseSpace, Xr // player standing at the real origin facing the real default -Z (i.e. one // who has never moved) yields (0, 0, 0), the same as this feature's // original naive reset-to-identity behavior. -bool xr_input_recenter_player_to_screen(void) { +// +// keepFacing=true (Reset Position) re-anchors only the position: the +// transform's yaw is kept, so the game quad does not rotate to follow the +// gaze. Same formulas, with theta taken from the current yaw instead. +static bool xr_input_recenter(bool keepFacing) { XrSpace localSpace = xr_session_get_local_space(); if (localSpace == XR_NULL_HANDLE || g_view_space == XR_NULL_HANDLE) return false; @@ -843,18 +1603,25 @@ bool xr_input_recenter_player_to_screen(void) { (headLoc.locationFlags & needed) != needed) return false; - float fx, fy, fz; - quat_rotate_vec(&headLoc.pose.orientation, 0.0f, 0.0f, -1.0f, &fx, &fy, &fz); - float len = sqrtf(fx * fx + fz * fz); - if (len < 1e-4f) - return false; // looking almost straight up/down - nothing sane to recenter onto - fx /= len; - fz /= len; + float theta; + if (keepFacing) { + float curX, curZ, curYawDeg; + xr_session_get_player_transform(&curX, &curZ, &curYawDeg); + theta = curYawDeg * QS_DEG_TO_RAD; + } else { + float fx, fy, fz; + quat_rotate_vec(&headLoc.pose.orientation, 0.0f, 0.0f, -1.0f, &fx, &fy, &fz); + float len = sqrtf(fx * fx + fz * fz); + if (len < 1e-4f) + return false; // looking almost straight up/down - nothing sane to recenter onto + fx /= len; + fz /= len; - // angle(0,-1) - angle(fx,fz), using the same atan2f(x, z) "angle" - // convention as xr_overlay_render_and_build_layer()'s own rotation - // comment (not the more usual atan2f(y, x)). - float theta = atan2f(0.0f, -1.0f) - atan2f(fx, fz); + // angle(0,-1) - angle(fx,fz), using the same atan2f(x, z) "angle" + // convention as xr_overlay_render_and_build_layer()'s own rotation + // comment (not the more usual atan2f(y, x)). + theta = atan2f(0.0f, -1.0f) - atan2f(fx, fz); + } float cosTheta = cosf(theta), sinTheta = sinf(theta); float hx = headLoc.pose.position.x, hz = headLoc.pose.position.z; float newX = -(hx * cosTheta + hz * sinTheta); @@ -865,8 +1632,45 @@ bool xr_input_recenter_player_to_screen(void) { return true; } +bool xr_input_recenter_player_to_screen(void) { return xr_input_recenter(false); } + +bool xr_input_reset_player_position(void) { return xr_input_recenter(true); } + void xr_input_skip_startup_recenter(void) { g_pending_startup_recenter = false; } +// sdl_events.c - the engine's own held-key table, indexed by its "SystemShock/Mac +// keycode" (see xr_input_sync_and_draw()'s watchdog). +extern unsigned char sshockKeyStates[256]; + +// See kKeyActions' own comment on why plain printable keys need +// SDL_TEXTINPUT in addition to SDL_KEYDOWN/UP (only jump needs Space +// specifically; every other plain-key action supplies its own character). +// Returns true only if SDL accepted the event. +static bool xr_input_push_text(char ch) { + SDL_Event textEvent; + SDL_zero(textEvent); + textEvent.type = SDL_TEXTINPUT; + textEvent.text.timestamp = SDL_GetTicks(); + textEvent.text.windowID = 0; + textEvent.text.text[0] = ch; + textEvent.text.text[1] = '\0'; + return SDL_PushEvent(&textEvent) == 1; +} + +static bool xr_input_push_key(const QsKeyAction *keyAction, bool down) { + SDL_Event event; + SDL_zero(event); + event.type = down ? SDL_KEYDOWN : SDL_KEYUP; + event.key.timestamp = SDL_GetTicks(); + event.key.windowID = 0; + event.key.state = down ? SDL_PRESSED : SDL_RELEASED; + event.key.repeat = 0; + event.key.keysym.scancode = keyAction->scancode; + event.key.keysym.sym = keyAction->keycode; + event.key.keysym.mod = keyAction->mod; + return SDL_PushEvent(&event) == 1; +} + void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw) { if (g_action_set == XR_NULL_HANDLE) return; @@ -877,6 +1681,20 @@ void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw) { syncInfo.activeActionSets = &activeSet; xr_check(xrSyncActions(g_session, &syncInfo), "xrSyncActions"); + // Cheap (no query on the runtime side, just a cached path lookup) - + // refreshed every frame so MenuOverlay's Controller Options tab always + // reflects whichever controller is actually connected right now. + XrInteractionProfileState profileState = {XR_TYPE_INTERACTION_PROFILE_STATE}; + if (xr_check(xrGetCurrentInteractionProfile(g_session, g_hand_path[LEFT], &profileState), + "xrGetCurrentInteractionProfile") && + profileState.interactionProfile != XR_NULL_PATH) { + uint32_t len = 0; + xrPathToString(g_instance, profileState.interactionProfile, + sizeof(g_active_profile_path), &len, g_active_profile_path); + } else { + g_active_profile_path[0] = '\0'; + } + // See g_pending_startup_recenter's own comment - retried every frame // (cheap: one xrLocateSpace call) until head tracking actually has a // valid pose to recenter onto, typically the very first frame. @@ -887,6 +1705,108 @@ void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw) { XrOverlay *keyboardOverlay = xr_session_get_keyboard_overlay(); bool menuVisible = xr_overlay_is_visible(menuOverlay); bool keyboardVisible = xr_overlay_is_visible(keyboardOverlay); + if (menuVisible != g_menu_was_visible) { + g_menu_was_visible = menuVisible; + qs_config_notify_menu_visibility(menuVisible); + } + + // Remappable controls: refresh every raw input's state, let an active + // capture consume a fresh press first, then dispatch the key-injecting + // actions. An action bound to a thumbstick direction is suppressed while + // the menu is open, since that stick drives the menu's snap-turn/smooth- + // move. mouse_click/mouse_double_click are resolved per hand inside the + // ray-dispatch loop below. + xr_input_update_raw_states(); + xr_input_process_capture(); + bool wantDown[QS_NUM_KEY_ACTIONS]; + for (size_t i = 0; i < QS_NUM_KEY_ACTIONS; i++) { + const QsKeyAction *keyAction = &kKeyActions[i]; + QsRawInput raw = s_binding[keyAction->action]; + wantDown[i] = raw != QS_RAW_NONE && !xr_input_action_suppressed_by_capture(keyAction->action) && + !(xr_input_raw_is_thumbstick_direction(raw) && menuVisible) && g_raw_down[raw]; + } + for (size_t i = 0; i < QS_NUM_KEY_ACTIONS; i++) { + const QsKeyAction *keyAction = &kKeyActions[i]; + bool down = wantDown[i]; + if (down == g_key_action_prev[i]) + continue; + // g_key_action_prev[i] is only advanced once every event this edge + // needs was actually accepted by SDL - a rejected push is retried + // next frame rather than leaving this action believing it already + // sent a press/release the engine never saw. + if (down) { + if (keyAction->needsTextInput && !xr_input_push_text(keyAction->textChar)) + continue; + if (!xr_input_push_key(keyAction, true)) + continue; + } else { + // The engine tracks held keys per key (sdl_events.c's + // sshockKeyStates[]), not per action - e.g. turn_left and + // strafe_left are both the Left arrow, differing only in + // modifier. A release for one must not clear the key while + // another action sharing it is still held. + int sibling = -1; + for (size_t j = 0; j < QS_NUM_KEY_ACTIONS; j++) { + if (j != i && wantDown[j] && kKeyActions[j].keycode == keyAction->keycode) { + sibling = (int)j; + break; + } + } + if (sibling >= 0) { + // Restores the still-held sibling's own modifiers if it + // was already down (a sibling only pressed this frame + // sends its own KEYDOWN when the loop reaches it). + if (g_key_action_prev[sibling] && !xr_input_push_key(&kKeyActions[sibling], true)) + continue; + } else if (!xr_input_push_key(keyAction, false)) { + continue; + } + } + LOGI("XR: key action %s %s", kActionNames[keyAction->action], down ? "DOWN" : "UP"); + g_key_action_prev[i] = down; + } + + // Watchdog for the continuously-polled movement keys: if the engine + // still has one held (sshockKeyStates[], sdl_events.c) while none of our + // actions is holding it, its release never took effect - the character + // would keep walking/turning until that key is next pressed and + // released. The grace period covers the normal 1-2 frame lag between + // pushing a release and the engine's next pump_events(). Skipped while + // the on-screen keyboard is up, since its own arrow/space keys (real + // SDL key events, held for as long as the trigger is) legitimately + // hold these too. + static const struct { + SDL_Keycode keycode; + Uint8 engineCode; // sdlKeyCodeToSSHOCKkeyCode()'s result (movekeys.h's CODE_*) + } kWatchedKeys[] = { + {SDLK_UP, 0x7E}, {SDLK_DOWN, 0x7D}, {SDLK_LEFT, 0x7B}, {SDLK_RIGHT, 0x7C}, {SDLK_SPACE, 0x31}, + {SDLK_RETURN, 0x24}, + }; + static Uint32 s_stuck_since[sizeof(kWatchedKeys) / sizeof(kWatchedKeys[0])]; + for (size_t w = 0; w < sizeof(kWatchedKeys) / sizeof(kWatchedKeys[0]); w++) { + const QsKeyAction *releaseWith = NULL; + bool heldByUs = false; + for (size_t i = 0; i < QS_NUM_KEY_ACTIONS; i++) { + if (kKeyActions[i].keycode != kWatchedKeys[w].keycode) + continue; + if (!releaseWith) + releaseWith = &kKeyActions[i]; + if (g_key_action_prev[i]) + heldByUs = true; + } + if (!releaseWith || heldByUs || keyboardVisible || sshockKeyStates[kWatchedKeys[w].engineCode] == 0) { + s_stuck_since[w] = 0; + continue; + } + Uint32 now = SDL_GetTicks(); + if (s_stuck_since[w] == 0) { + s_stuck_since[w] = now; + } else if (now - s_stuck_since[w] > 200) { + LOGI("XR: key watchdog releasing stuck engine key 0x%02x", kWatchedKeys[w].engineCode); + xr_input_push_key(releaseWith, false); + s_stuck_since[w] = now; + } + } // A drag can't get stuck active across a hide/reopen - e.g. Close // being hit on one hand mid-drag on the other, or the session ending. @@ -930,19 +1850,44 @@ void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw) { float keyboardCursorU = 0.0f, keyboardCursorV = 0.0f; for (int hand = 0; hand < 2; hand++) { - XrActionStateGetInfo selectInfo = {XR_TYPE_ACTION_STATE_GET_INFO}; - selectInfo.action = g_select_click_action; - selectInfo.subactionPath = g_hand_path[hand]; - XrActionStateBoolean selectState = {XR_TYPE_ACTION_STATE_BOOLEAN}; - xrGetActionStateBoolean(g_session, &selectInfo, &selectState); - bool selectDown = selectState.isActive && selectState.currentState; + // The click signal shared by keyboard, menu and game-quad dispatch, + // resolved once per hand: while the menu is open it is the trigger + // (fixed, so the menu is always operable whatever mouse_click is + // bound to); otherwise it is mouse_click's binding, so an on-screen + // keyboard press uses the same click as the game. + bool selectDown; + if (menuVisible) { + XrActionStateGetInfo selectInfo = {XR_TYPE_ACTION_STATE_GET_INFO}; + selectInfo.action = g_select_click_action; + selectInfo.subactionPath = g_hand_path[hand]; + XrActionStateBoolean selectState = {XR_TYPE_ACTION_STATE_BOOLEAN}; + xrGetActionStateBoolean(g_session, &selectInfo, &selectState); + selectDown = selectState.isActive && selectState.currentState; + } else { + selectDown = !xr_input_action_suppressed_by_capture(QS_ACTION_MOUSE_CLICK) && + xr_input_raw_state_for_hand(s_binding[QS_ACTION_MOUSE_CLICK], hand); + } bool selectDownEdge = selectDown && !g_prev_select[hand]; bool selectUpEdge = !selectDown && g_prev_select[hand]; if (selectDown != g_prev_select[hand]) { - LOGI("XR: %s trigger %s", kHandName[hand], selectDown ? "DOWN" : "UP"); + LOGI("XR: %s select %s", kHandName[hand], selectDown ? "DOWN" : "UP"); g_prev_select[hand] = selectDown; } + // mouse_double_click - game-quad-only (see its own dispatch further + // down) and only while the menu is closed, matching the reasoning + // above (double-click is a gameplay interaction primitive, not a + // menu one - keyboard keys have no double-click semantic either). + bool doubleClickEdge = false; + if (!menuVisible && !xr_input_action_suppressed_by_capture(QS_ACTION_MOUSE_DOUBLE_CLICK)) { + bool doubleClickDown = + xr_input_raw_state_for_hand(s_binding[QS_ACTION_MOUSE_DOUBLE_CLICK], hand); + doubleClickEdge = doubleClickDown && !g_prev_double_click[hand]; + g_prev_double_click[hand] = doubleClickDown; + } else { + g_prev_double_click[hand] = false; + } + if (hand == LEFT) { XrActionStateGetInfo menuInfo = {XR_TYPE_ACTION_STATE_GET_INFO}; menuInfo.action = g_menu_toggle_action; @@ -960,8 +1905,16 @@ void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw) { xr_overlay_toggle_visible(menuOverlay); g_menu_prev_hit[LEFT] = g_menu_prev_hit[RIGHT] = false; g_menu_touch_active[LEFT] = g_menu_touch_active[RIGHT] = false; - if (wasHidden && xr_overlay_is_visible(menuOverlay)) + if (wasHidden && xr_overlay_is_visible(menuOverlay)) { xr_input_recenter_menu(menuOverlay, baseSpace, time); + } else if (!wasHidden && !xr_overlay_is_visible(menuOverlay) && g_capture_active) { + // The menu closed with a capture pending: cancel it and + // notify MenuOverlay, whose capturingActionIndex would + // otherwise stay set. + int cancelledAction = (int)g_capture_action; + g_capture_active = false; + qs_config_notify_control_capture_result(cancelledAction, NULL, -1, NULL); + } } } else if (!menuDown && g_prev_menu) { LOGI("XR: menu-toggle button UP"); @@ -974,8 +1927,10 @@ void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw) { // scheme). g_turn_ready is a hysteresis latch (see its own // declaration comment) so holding the stick past the threshold // turns exactly once, not once per frame - it only re-arms once - // the stick has come back near center. - if (menuVisible) { + // the stick has come back near center. Also suppressed during a + // Controller Options capture, so a stick pressed then doesn't + // also snap-turn the view. + if (menuVisible && !g_capture_active && !g_controller_options_tab_open) { XrActionStateGetInfo turnInfo = {XR_TYPE_ACTION_STATE_GET_INFO}; turnInfo.action = g_thumbstick_action; turnInfo.subactionPath = g_hand_path[LEFT]; @@ -1002,90 +1957,15 @@ void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw) { } if (hand == RIGHT) { - XrActionStateGetInfo spaceKeyInfo = {XR_TYPE_ACTION_STATE_GET_INFO}; - spaceKeyInfo.action = g_space_key_action; - spaceKeyInfo.subactionPath = g_hand_path[RIGHT]; - XrActionStateBoolean spaceKeyState = {XR_TYPE_ACTION_STATE_BOOLEAN}; - xrGetActionStateBoolean(g_session, &spaceKeyInfo, &spaceKeyState); - bool spaceKeyDown = spaceKeyState.isActive && spaceKeyState.currentState; - if (spaceKeyDown != g_prev_space_key) { - LOGI("XR: A button (space key) %s", spaceKeyDown ? "DOWN" : "UP"); - if (spaceKeyDown) { - // The engine's pump_events() (sdl_events.c) only queues a - // KEYDOWN here for non-printable keys (ascii < 32) - - // printable keys like Space are expected to arrive via - // SDL_TEXTINPUT instead (that's the branch that actually - // queues the KBS_DOWN event the tutorial-dismiss handler - // checks for), so send that too, matching what real - // keyboard input generates for a Space press. - // - // sdl_events.c's SDL_TEXTINPUT handling also sets - // sshockKeyStates[Space] = pressed - the same array - // Space's own keybind (M_JUMP, see MacSrc/Prefs.c's - // default keybinds.txt) polls every physics tick via - // ui_poll_keyboard(). It's only ever cleared again by a - // matching SDL_KEYUP, sent below once this button's real - // physical up-edge fires (spaceKeyDown false) - not - // synthesized here alongside the press, since pushing - // both in the same batch would set and clear it within - // one pump_events() call, before the poll (which runs - // once per frame, before pump_events) ever sees the - // press. - SDL_Event textEvent; - SDL_zero(textEvent); - textEvent.type = SDL_TEXTINPUT; - textEvent.text.timestamp = SDL_GetTicks(); - textEvent.text.windowID = 0; - textEvent.text.text[0] = ' '; - textEvent.text.text[1] = '\0'; - SDL_PushEvent(&textEvent); - } - SDL_Event event; - SDL_zero(event); - event.type = spaceKeyDown ? SDL_KEYDOWN : SDL_KEYUP; - event.key.timestamp = SDL_GetTicks(); - event.key.windowID = 0; - event.key.state = spaceKeyDown ? SDL_PRESSED : SDL_RELEASED; - event.key.repeat = 0; - event.key.keysym.scancode = SDL_SCANCODE_SPACE; - event.key.keysym.sym = SDLK_SPACE; - event.key.keysym.mod = KMOD_NONE; - SDL_PushEvent(&event); - g_prev_space_key = spaceKeyDown; - } - - XrActionStateGetInfo forwardKeyInfo = {XR_TYPE_ACTION_STATE_GET_INFO}; - forwardKeyInfo.action = g_forward_key_action; - forwardKeyInfo.subactionPath = g_hand_path[RIGHT]; - XrActionStateBoolean forwardKeyState = {XR_TYPE_ACTION_STATE_BOOLEAN}; - xrGetActionStateBoolean(g_session, &forwardKeyInfo, &forwardKeyState); - bool forwardKeyDown = forwardKeyState.isActive && forwardKeyState.currentState; - if (forwardKeyDown != g_prev_forward_key) { - LOGI("XR: B button (forward key) %s", forwardKeyDown ? "DOWN" : "UP"); - SDL_Event event; - SDL_zero(event); - event.type = forwardKeyDown ? SDL_KEYDOWN : SDL_KEYUP; - event.key.timestamp = SDL_GetTicks(); - event.key.windowID = 0; - event.key.state = forwardKeyDown ? SDL_PRESSED : SDL_RELEASED; - event.key.repeat = 0; - event.key.keysym.scancode = SDL_SCANCODE_UP; - event.key.keysym.sym = SDLK_UP; - event.key.keysym.mod = KMOD_NONE; - SDL_PushEvent(&event); - g_prev_forward_key = forwardKeyDown; - } - - // Smooth move - only while the menu is open, same reasoning as - // the left stick's snap turn above. g_last_move_ticks is - // refreshed every frame regardless of menuVisible (not just - // while actively moving) so dt is always "time since the - // previous frame" and never goes stale/huge across a stretch - // with the menu closed. + // Smooth move - only while the menu is open, with the same + // suppression as the snap turn above. g_last_move_ticks is + // refreshed every frame so dt is always the time since the + // previous frame. Uint32 nowTicks = SDL_GetTicks(); Uint32 lastMoveTicks = g_last_move_ticks; g_last_move_ticks = nowTicks; - if (menuVisible && lastMoveTicks != 0) { + if (menuVisible && !g_capture_active && !g_controller_options_tab_open && + lastMoveTicks != 0) { XrActionStateGetInfo moveInfo = {XR_TYPE_ACTION_STATE_GET_INFO}; moveInfo.action = g_thumbstick_action; moveInfo.subactionPath = g_hand_path[RIGHT]; @@ -1225,6 +2105,16 @@ void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw) { xr_mouse_click(false); g_game_touch_active[hand] = false; } + if (doubleClickEdge && gameHit) { + // Two complete click-down/up pairs, same frame - the engine's + // own double-click detection (input.c) compares two MOUSE_LUP + // events' timing/position, so this is indistinguishable from a + // fast real double-click, no engine change needed. + xr_mouse_click(true); + xr_mouse_click(false); + xr_mouse_click(true); + xr_mouse_click(false); + } // gameHit implies gamePlaneHit (both only ever set together above), // kept as a separate out-param for symmetry with // xr_input_try_overlay()'s outPlaneHit/outPlaneDistance pair. @@ -1238,6 +2128,12 @@ void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw) { if (keyboardVisible) xr_overlay_update_cursor(keyboardOverlay, keyboardCursorU, keyboardCursorV, keyboardCursorHit); + + // Snapshot for next frame's edge detection - after everything above + // (capture-mode scan, key-action dispatch, mouse_click/double_click + // resolution) has finished comparing this frame's g_raw_down against + // the previous frame's values. + memcpy(g_raw_prev, g_raw_down, sizeof(g_raw_down)); } bool xr_input_get_beam_layer(int hand, XrCompositionLayerQuad *out_quad) { @@ -1248,6 +2144,13 @@ bool xr_input_get_beam_layer(int hand, XrCompositionLayerQuad *out_quad) { } void xr_input_shutdown(void) { + // Clearing g_key_action_prev below without releasing what it says is + // held would leave the engine's own held-key state stuck on (nothing + // else ever sends that key's release). + for (size_t i = 0; i < QS_NUM_KEY_ACTIONS; i++) { + if (g_key_action_prev[i]) + xr_input_push_key(&kKeyActions[i], false); + } for (int hand = 0; hand < 2; hand++) { if (g_aim_space[hand] != XR_NULL_HANDLE) xrDestroySpace(g_aim_space[hand]); @@ -1263,13 +2166,23 @@ void xr_input_shutdown(void) { g_aim_pose_action = XR_NULL_HANDLE; g_select_click_action = XR_NULL_HANDLE; g_menu_toggle_action = XR_NULL_HANDLE; - g_space_key_action = XR_NULL_HANDLE; - g_forward_key_action = XR_NULL_HANDLE; + g_a_click_action = XR_NULL_HANDLE; + g_b_click_action = XR_NULL_HANDLE; + g_x_click_action = XR_NULL_HANDLE; + g_y_click_action = XR_NULL_HANDLE; + g_squeeze_action = XR_NULL_HANDLE; + g_thumbstick_click_action = XR_NULL_HANDLE; g_thumbstick_action = XR_NULL_HANDLE; g_instance = XR_NULL_HANDLE; g_session = XR_NULL_HANDLE; memset(g_prev_select, 0, sizeof(g_prev_select)); + memset(g_prev_double_click, 0, sizeof(g_prev_double_click)); + memset(g_key_action_prev, 0, sizeof(g_key_action_prev)); + memset(g_raw_down, 0, sizeof(g_raw_down)); + memset(g_raw_prev, 0, sizeof(g_raw_prev)); + memset(g_stick_digital, 0, sizeof(g_stick_digital)); + g_capture_active = false; memset(g_game_prev_hit, 0, sizeof(g_game_prev_hit)); memset(g_game_touch_active, 0, sizeof(g_game_touch_active)); memset(g_menu_prev_hit, 0, sizeof(g_menu_prev_hit)); @@ -1278,8 +2191,6 @@ void xr_input_shutdown(void) { memset(g_keyboard_touch_active, 0, sizeof(g_keyboard_touch_active)); g_beam_quad_valid[LEFT] = g_beam_quad_valid[RIGHT] = false; g_prev_menu = false; - g_prev_space_key = false; - g_prev_forward_key = false; g_keyboard_drag.dragging = false; g_keyboard_drag.hand = -1; g_menu_drag.dragging = false; diff --git a/android/app/src/main/cpp/xr_input.h b/android/app/src/main/cpp/xr_input.h index 6c56ee4..0e49202 100644 --- a/android/app/src/main/cpp/xr_input.h +++ b/android/app/src/main/cpp/xr_input.h @@ -67,27 +67,22 @@ void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw); 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're currently really standing/facing - a pure, -// live "return to a sane view of the screen" action, unrelated to any -// stored/config value (unlike xr_session_set_player_transform()'s other -// callers, this never reads or writes questshock.config.json - it's driven -// entirely by the player's current real head pose, the same way -// xr_input_sync_and_draw()'s own menu re-center is). Called from the Main -// Menu tab's "Reset Position" button (via qs_config.c's cross-thread -// dispatch - see qs_config_request_reset_position()), but doesn't touch -// anything else qs_config.c owns; also called once automatically, from -// inside xr_input_sync_and_draw() itself, on whichever frame first has a -// valid head pose after startup (see g_pending_startup_recenter's own -// comment) - but only as a *fallback* default for when there's no -// complete saved player_position to fall back on instead (see -// xr_input_skip_startup_recenter() below), since a config-loaded value -// describes a transform relative to a *previous* session's real-space -// origin, which is only meaningful if that previous position was actually -// captured (LOCAL space's own origin isn't a stable point across -// launches). Returns false (no-op otherwise) if no valid head pose was -// available to recenter onto yet. +// 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 @@ -96,6 +91,60 @@ bool xr_input_recenter_player_to_screen(void); // 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); #ifdef __cplusplus diff --git a/android/app/src/main/java/de/ladkau/questshock/MenuOverlay.java b/android/app/src/main/java/de/ladkau/questshock/MenuOverlay.java index 8a1980a..807303d 100644 --- a/android/app/src/main/java/de/ladkau/questshock/MenuOverlay.java +++ b/android/app/src/main/java/de/ladkau/questshock/MenuOverlay.java @@ -2,22 +2,43 @@ package de.ladkau.questshock; import android.app.Activity; import android.app.ActivityManager; +import android.app.AlertDialog; import android.content.Context; import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.Paint; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; import android.graphics.drawable.StateListDrawable; import android.os.Handler; import android.os.Looper; +import android.util.Log; import android.util.TypedValue; import android.view.Gravity; +import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.widget.Button; import android.widget.FrameLayout; +import android.widget.ImageView; import android.widget.LinearLayout; +import android.widget.ScrollView; import android.widget.TextView; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; +import java.util.HashSet; import java.util.Locale; +import java.util.Objects; +import java.util.Set; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; /** * The main menu launcher quad (see OverlayPanel for the shared off-screen- @@ -73,6 +94,33 @@ public class MenuOverlay extends OverlayPanel { // this same language's own Strings.java translation is filed under. private static final String[] LANGUAGE_KEYS = {"english", "french", "german"}; + // Controller Options tab actions. Order must match xr_input.c's + // QsControlAction enum/kActionNames[]: the indices go straight to + // nativeRequestBeginControlCapture()/nativeGetControlBindingKey(). Row + // labels are Strings.get("action_" + ACTION_KEYS[i], ...). The hotkey + // actions are unbound by default; xr_input.c lists the HotKeyLookup[] + // entries that are not exposed. + private static final String[] ACTION_KEYS = { + "move_forward", "move_back", "strafe_left", "strafe_right", + "turn_left", "turn_right", "jump", "mouse_click", "mouse_double_click", + "stand", "crouch", "prone", "toggle_freelook", "pause", + "full_view", "normal_view", "map_view", "save_game", "load_game", + "select_grenade", "arm_grenade", "select_drug", "use_drug", + "toggle_online_help", "show_help", "toggle_music", "quit", + "cycle_weapon_next", "cycle_weapon_prev", "cycle_detail", + "reload_weapon_primary", "reload_weapon_secondary", "hud_color", + "clear_fullscreen", + "mfd_left_1", "mfd_left_2", "mfd_left_3", "mfd_left_4", "mfd_left_5", + "mfd_right_1", "mfd_right_2", "mfd_right_3", "mfd_right_4", "mfd_right_5", + "fire_weapon", + }; + private static final String CONTROLLER_PROFILES_DIR = QuestShockActivity.GAME_DIR + "/controller_profiles"; + // Shown (with its own explanatory note - see Strings.java's + // "generic_profile_note") whenever no bundled profile's + // interaction_profile matches the runtime's active one, same fallback + // reasoning as qs_config.c's own generic khr/simple_controller binding. + private static final String GENERIC_PROFILE_FILENAME = "generic.json"; + private static MenuOverlay instance; // Kept so relayoutAndRedraw() (see its own comment) can re-measure/ @@ -87,13 +135,15 @@ public class MenuOverlay extends OverlayPanel { private TextView titleView; private TextView statsView; - // Content area (tabs) - only one of mainMenuTab/videoOptionsTab is - // VISIBLE at a time; currentTab tracks which. + // Content area (tabs) - only one of mainMenuTab/videoOptionsTab/ + // controllerOptionsTab is VISIBLE at a time; currentTab tracks which. private View mainMenuTab; private View videoOptionsTab; + private View controllerOptionsTab; private View currentTab; private Button mainMenuTabButton; private Button videoOptionsTabButton; + private Button controllerOptionsTabButton; // Main Menu tab's own two states (only one VISIBLE at a time, within // mainMenuTab) - mainMenuContent is the normal state (button row + @@ -134,6 +184,32 @@ public class MenuOverlay extends OverlayPanel { private Button[] gameScreenWidthButtons; private Button[] gameScreenDistanceButtons; + // Controller Options tab controls - one row per ACTION_KEYS entry + // (controlRows[i] toggles VISIBLE/GONE per refreshControllerOptionsSelection()'s + // active-profile filtering; controlActionLabels/controlBindingButtons + // stay 1:1 with ACTION_KEYS regardless of visibility). + private ImageView controllerImageView; + private TextView controllerProfileLabel; + private TextView controllerGenericNoteView; + private ScrollView controllerScrollView; + private ScrollBarView controllerScrollBar; + private LinearLayout[] controlRows; + private TextView[] controlActionLabels; + private Button[] controlBindingButtons; + private Button controlResetButton; + // -1 = no row currently listening for a press; otherwise an index into + // ACTION_KEYS - the one row whose text refreshControllerOptionsSelection() + // leaves alone (showing the "Press a button…" placeholder) rather than + // overwriting with its actual current binding. + private int capturingActionIndex = -1; + // Cheap memoization keyed on the active interaction profile path string + // (xr_input.c only recomputes that once per frame anyway) - avoids + // re-reading/re-parsing every *.json file in CONTROLLER_PROFILES_DIR on + // every refreshControllerOptionsSelection() call (periodic, every + // STATS_REFRESH_MS while this tab is visible). + private String cachedProfilePath; + private ControllerProfile cachedProfile; + private int currentLanguage = Strings.ENGLISH; private final Handler handler = new Handler(Looper.getMainLooper()); @@ -167,11 +243,15 @@ public class MenuOverlay extends OverlayPanel { FrameLayout content = new FrameLayout(activity); mainMenuTab = buildMainMenuTab(); videoOptionsTab = buildVideoOptionsTab(); + controllerOptionsTab = buildControllerOptionsTab(); content.addView(mainMenuTab, new FrameLayout.LayoutParams( FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)); content.addView(videoOptionsTab, new FrameLayout.LayoutParams( FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)); + content.addView(controllerOptionsTab, new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)); videoOptionsTab.setVisibility(View.GONE); + controllerOptionsTab.setVisibility(View.GONE); currentTab = mainMenuTab; panel.addView(content, new LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f)); @@ -435,6 +515,398 @@ public class MenuOverlay extends OverlayPanel { return tab; } + // Horizontal scroll bar for the Controller Options tab - a track with a + // round thumb at `fraction` (0..1) along it. See + // buildControllerOptionsTab() for why this isn't a SeekBar. + private static final class ScrollBarView extends View { + private static final float PAD = 20f; + private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); + private float fraction; + + ScrollBarView(Context context) { + super(context); + paint.setStrokeCap(Paint.Cap.ROUND); + } + + void setFraction(float value) { + fraction = Math.max(0f, Math.min(1f, value)); + invalidate(); + } + + float fractionForX(float x) { + float usable = getWidth() - 2 * PAD; + return usable > 0 ? Math.max(0f, Math.min(1f, (x - PAD) / usable)) : 0f; + } + + @Override + protected void onDraw(Canvas canvas) { + float cy = getHeight() / 2f; + float left = PAD; + float right = getWidth() - PAD; + paint.setColor(0xFF606060); + paint.setStrokeWidth(8f); + canvas.drawLine(left, cy, right, cy, paint); + paint.setColor(0xFFE0E0E0); + canvas.drawCircle(left + fraction * (right - left), cy, 16f, paint); + } + } + + // Controller Options tab (see CONFIG.md's "Player Controls"): an optional + // controller illustration and profile name at the top (from a matching + // res/controller_profiles/*.json and image, see + // refreshControllerOptionsSelection()), one row per ACTION_KEYS entry + // (label plus a button showing/capturing its binding), and a Reset to + // Defaults button. All rows are built once; + // refreshControllerOptionsSelection() shows or hides each to match the + // active controller profile. + // + // The rows don't fit the panel's fixed height, so they sit in a + // ScrollView. OverlayPanel.handleTouch() only delivers ACTION_DOWN and + // ACTION_UP (never ACTION_MOVE), so drag-to-scroll cannot work; a scroll + // bar below the list is tapped to jump to a position instead. A stock + // SeekBar cannot serve as that bar: it defers a press until an + // ACTION_MOVE past the touch slop (View.shouldDelayChildPressedState() + // defaults to true for every ViewGroup), so a tap does nothing. + // ScrollBarView computes the tapped position from its own touch + // listener, like each button's CLICK_ON_TOUCH_UP. + private View buildControllerOptionsTab() { + LinearLayout tab = new LinearLayout(activity); + tab.setOrientation(LinearLayout.VERTICAL); + + LinearLayout scrollContent = new LinearLayout(activity); + scrollContent.setOrientation(LinearLayout.VERTICAL); + scrollContent.setPadding(24, 16, 24, 8); + + controllerImageView = new ImageView(activity); + controllerImageView.setVisibility(View.GONE); + scrollContent.addView(controllerImageView, + new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 140)); + + controllerProfileLabel = new TextView(activity); + controllerProfileLabel.setTextColor(0xFFFFFFFF); + controllerProfileLabel.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14); + controllerProfileLabel.setGravity(Gravity.CENTER); + scrollContent.addView(controllerProfileLabel); + + controllerGenericNoteView = new TextView(activity); + controllerGenericNoteView.setTextColor(0xFFFFD060); + controllerGenericNoteView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 13); + controllerGenericNoteView.setGravity(Gravity.CENTER); + controllerGenericNoteView.setPadding(0, 4, 0, 4); + controllerGenericNoteView.setVisibility(View.GONE); + scrollContent.addView(controllerGenericNoteView); + + controlRows = new LinearLayout[ACTION_KEYS.length]; + controlActionLabels = new TextView[ACTION_KEYS.length]; + controlBindingButtons = new Button[ACTION_KEYS.length]; + // Rows are created in display order (Fire Weapon first, then the + // rest in ACTION_KEYS order); every array below stays indexed by + // the real action index, which must keep matching xr_input.c. + int fireIndex = Arrays.asList(ACTION_KEYS).indexOf("fire_weapon"); + for (int d = 0; d < ACTION_KEYS.length; d++) { + final int i = d == 0 ? fireIndex : (d - 1 < fireIndex ? d - 1 : d); + final int actionIndex = i; + LinearLayout row = new LinearLayout(activity); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setGravity(Gravity.CENTER_VERTICAL); + + TextView label = new TextView(activity); + label.setTextColor(0xFFFFFFFF); + label.setTextSize(TypedValue.COMPLEX_UNIT_SP, 15); + label.setText(Strings.get("action_" + ACTION_KEYS[i], currentLanguage)); + row.addView(label, + new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)); + + Button bindingButton = new Button(activity); + bindingButton.setOnClickListener(v -> onControlBindingButtonTapped(actionIndex)); + bindingButton.setOnTouchListener(CLICK_ON_TOUCH_UP); + bindingButton.setBackground(selectableButtonBackground()); + bindingButton.setTextColor(buttonTextColors()); + registerHoverable(bindingButton); + LinearLayout.LayoutParams bindingParams = new LinearLayout.LayoutParams(0, 64, 1f); + bindingParams.setMargins(4, 4, 4, 4); + row.addView(bindingButton, bindingParams); + + scrollContent.addView(row, new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)); + controlRows[i] = row; + controlActionLabels[i] = label; + controlBindingButtons[i] = bindingButton; + } + + controllerScrollView = new ScrollView(activity); + controllerScrollView.addView(scrollContent, new ScrollView.LayoutParams( + ScrollView.LayoutParams.MATCH_PARENT, ScrollView.LayoutParams.WRAP_CONTENT)); + tab.addView(controllerScrollView, new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f)); + + // The scroll bar (see this method's comment above). Not a SeekBar: + // ProgressBar records its constructing thread (the native render + // thread here) and, when set from the UI thread, defers the visual + // update through View.post(), which never runs on this unattached + // tree. ScrollBarView draws from a plain field instead. It is a + // sibling of controllerScrollView, so it stays fixed below the + // scrolling rows, and it is always shown (a tap is a no-op when + // nothing overflows). + controllerScrollBar = new ScrollBarView(activity); + controllerScrollBar.setOnTouchListener((v, event) -> { + if (event.getAction() != MotionEvent.ACTION_DOWN) { + return true; + } + float fraction = controllerScrollBar.fractionForX(event.getX()); + int range = scrollContent.getHeight() - controllerScrollView.getHeight(); + if (range > 0) { + controllerScrollView.scrollTo(0, Math.round(range * fraction)); + } + controllerScrollBar.setFraction(fraction); + return true; + }); + // Keeps the thumb tracking the actual scroll position however it + // changed, not just from a tap on the bar itself. + controllerScrollView.setOnScrollChangeListener((v, scrollX, scrollY, oldX, oldY) -> { + int range = scrollContent.getHeight() - controllerScrollView.getHeight(); + controllerScrollBar.setFraction(range > 0 ? (float) scrollY / range : 0f); + }); + LinearLayout.LayoutParams scrollBarParams = + new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 48); + scrollBarParams.setMargins(24, 0, 24, 0); + tab.addView(controllerScrollBar, scrollBarParams); + + LinearLayout resetRow = new LinearLayout(activity); + resetRow.setOrientation(LinearLayout.HORIZONTAL); + resetRow.setGravity(Gravity.CENTER); + resetRow.setPadding(0, 4, 0, 8); + controlResetButton = new Button(activity); + controlResetButton.setText(Strings.get("reset_to_defaults", currentLanguage)); + controlResetButton.setOnClickListener(v -> onResetControlsClicked()); + controlResetButton.setOnTouchListener(CLICK_ON_TOUCH_UP); + controlResetButton.setBackground(buttonBackground()); + controlResetButton.setTextColor(buttonTextColors()); + registerHoverable(controlResetButton); + LinearLayout.LayoutParams resetParams = + new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, 56); + resetParams.setMargins(8, 8, 8, 8); + resetRow.addView(controlResetButton, resetParams); + tab.addView(resetRow); + + return tab; + } + + // Tapping a binding button starts capture for that row (or cancels it, + // if that row is the one already listening) - see xr_input.h's own + // capture_begin()/capture_cancel() comment. The row's own text is set + // to the localized placeholder immediately for instant feedback, rather + // than waiting for onControlCaptureResult() - which never arrives at + // all on cancel. + private void onControlBindingButtonTapped(int actionIndex) { + if (capturingActionIndex == actionIndex) { + nativeRequestCancelControlCapture(); + capturingActionIndex = -1; + refreshControllerOptionsSelection(); + return; + } + capturingActionIndex = actionIndex; + nativeRequestBeginControlCapture(actionIndex); + controlBindingButtons[actionIndex].setText(Strings.get("capture_prompt", currentLanguage)); + controlBindingButtons[actionIndex].setSelected(true); + relayoutAndRedraw(); + } + + private void onResetControlsClicked() { + if (capturingActionIndex != -1) { + nativeRequestCancelControlCapture(); + capturingActionIndex = -1; + } + nativeRequestResetPlayerControls(); + refreshControllerOptionsSelection(); + } + + // Reflects the currently-active controller profile + bindings onto the + // tab - called when the tab is shown (switchTab()) and, while it's the + // visible tab, on the same periodic timer as the header stats + // (refreshStats()). Mirrors refreshVideoOptionsSelection()'s "query + // fresh, stays correct even if changed some other way" reasoning, + // except for whichever single row is mid-capture (see + // capturingActionIndex's own comment) - that one is left untouched so + // its placeholder isn't clobbered by this same periodic refresh. + private void refreshControllerOptionsSelection() { + String activeProfilePath = nativeGetActiveInteractionProfilePath(); + ControllerProfile profile = loadControllerProfile(activeProfilePath); + + boolean isGeneric = profile == null || GENERIC_PROFILE_FILENAME.equals(profile.sourceFileName); + controllerGenericNoteView.setVisibility(isGeneric ? View.VISIBLE : View.GONE); + if (isGeneric) { + controllerGenericNoteView.setText(Strings.get("generic_profile_note", currentLanguage)); + } + + Bitmap image = profile != null ? loadProfileImage(profile) : null; + if (image != null) { + controllerImageView.setImageBitmap(image); + controllerImageView.setVisibility(View.VISIBLE); + } else { + controllerImageView.setVisibility(View.GONE); + } + controllerProfileLabel.setText(profile != null ? profile.name : ""); + + for (int i = 0; i < ACTION_KEYS.length; i++) { + boolean relevant = profile == null || isActionRelevantForProfile(profile, nativeGetControlBindingKey(i)); + controlRows[i].setVisibility(relevant ? View.VISIBLE : View.GONE); + if (i == capturingActionIndex) { + continue; + } + controlBindingButtons[i].setText(rawInputDisplayName(nativeGetControlBindingKey(i))); + controlBindingButtons[i].setSelected(false); + } + relayoutAndRedraw(); + } + + private String rawInputDisplayName(String rawKey) { + return Strings.get("raw_" + rawKey, currentLanguage); + } + + // "trigger"/"squeeze" are the two symmetric default bindings (see + // xr_input.h's own comment) - relevant to a profile if it has *either* + // hand's concrete equivalent; "none" (unbound) always shows, so a + // player can see - and rebind - an action this profile happens not to + // default anything useful to. Every other key is a concrete raw input, + // checked against the profile's own controls list at that list's own + // granularity - see baseControlId()'s own comment. + private static boolean isActionRelevantForProfile(ControllerProfile profile, String rawKey) { + if ("none".equals(rawKey)) { + return true; + } + if ("trigger".equals(rawKey)) { + return profile.controlIds.contains("left_trigger") || profile.controlIds.contains("right_trigger"); + } + if ("squeeze".equals(rawKey)) { + return profile.controlIds.contains("left_squeeze") || profile.controlIds.contains("right_squeeze"); + } + return profile.controlIds.contains(baseControlId(rawKey)); + } + + // The 8 digitized thumbstick-direction raw inputs (see xr_input.h's own + // comment on QsRawInput) all point at the same physical stick, so + // res/controller_profiles/*.json lists just one "left_thumbstick"/ + // "right_thumbstick" control id per hand (one diagram anchor point, + // one row-visibility check) rather than 4 - this maps any of the 4 + // direction-specific names back to that shared id. Every other raw + // input (including the *_thumbstick_click ones, which are listed as + // their own separate control id - see CONFIG.md) already matches a + // profile's controls list directly, unchanged. + private static String baseControlId(String rawKey) { + switch (rawKey) { + case "left_thumbstick_up": + case "left_thumbstick_down": + case "left_thumbstick_left": + case "left_thumbstick_right": + return "left_thumbstick"; + case "right_thumbstick_up": + case "right_thumbstick_down": + case "right_thumbstick_left": + case "right_thumbstick_right": + return "right_thumbstick"; + default: + return rawKey; + } + } + + // Parsed res/controller_profiles/*.json content (see CONFIG.md) - name/ + // interaction_profile/image/controls straight from the file, plus the + // lazily-decoded image Bitmap itself (decoded at most once per profile + // object, not on every refresh - see loadProfileImage()). + private static final class ControllerProfile { + String sourceFileName; + String name; + String interactionProfile; + String imageFileName; + final Set controlIds = new HashSet<>(); + Bitmap image; + boolean imageLoadFailed; + } + + // Scans CONTROLLER_PROFILES_DIR for the *.json file whose + // interaction_profile matches activeProfilePath, falling back to + // GENERIC_PROFILE_FILENAME if none match (e.g. the folder is missing, + // or a future runtime reports a profile this build doesn't ship a file + // for) - same fallback reasoning as xr_input.c's own + // khr/simple_controller binding. Result is memoized per active-profile- + // path string (see cachedProfilePath's own comment); returns null only + // if even the generic fallback file can't be found/parsed. + private ControllerProfile loadControllerProfile(String activeProfilePath) { + if (cachedProfile != null && Objects.equals(cachedProfilePath, activeProfilePath)) { + return cachedProfile; + } + File dir = new File(CONTROLLER_PROFILES_DIR); + File[] files = dir.listFiles((d, name) -> name.endsWith(".json")); + ControllerProfile matched = null; + ControllerProfile generic = null; + if (files != null) { + for (File file : files) { + ControllerProfile parsed = parseControllerProfileFile(file); + if (parsed == null) { + continue; + } + if (activeProfilePath != null && activeProfilePath.equals(parsed.interactionProfile)) { + matched = parsed; + } + if (GENERIC_PROFILE_FILENAME.equals(file.getName())) { + generic = parsed; + } + } + } + ControllerProfile result = matched != null ? matched : generic; + cachedProfile = result; + cachedProfilePath = activeProfilePath; + return result; + } + + private static ControllerProfile parseControllerProfileFile(File file) { + try (InputStream in = new FileInputStream(file)) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buf = new byte[4096]; + int n; + while ((n = in.read(buf)) > 0) { + out.write(buf, 0, n); + } + JSONObject json = new JSONObject(out.toString("UTF-8")); + ControllerProfile profile = new ControllerProfile(); + profile.sourceFileName = file.getName(); + profile.name = json.optString("name", file.getName()); + profile.interactionProfile = json.isNull("interaction_profile") ? null + : json.optString("interaction_profile", null); + profile.imageFileName = json.isNull("image") ? null : json.optString("image", null); + JSONArray controls = json.optJSONArray("controls"); + if (controls != null) { + for (int i = 0; i < controls.length(); i++) { + String id = controls.getJSONObject(i).optString("id", null); + if (id != null) { + profile.controlIds.add(id); + } + } + } + return profile; + } catch (IOException | JSONException e) { + Log.e("QuestShock", "Could not parse controller profile " + file, e); + return null; + } + } + + // Decoded at most once per ControllerProfile instance (cached alongside + // it, see loadControllerProfile()'s own memoization) - a missing/ + // corrupt image file (e.g. the illustration hasn't been added to + // res/controller_profiles/ yet - see CONFIG.md) just means the tab + // shows no image, not a crash; imageLoadFailed avoids retrying the + // decode on every subsequent refresh. + private static Bitmap loadProfileImage(ControllerProfile profile) { + if (profile.imageFileName == null || profile.imageLoadFailed) { + return profile.image; + } + File file = new File(CONTROLLER_PROFILES_DIR, profile.imageFileName); + profile.image = BitmapFactory.decodeFile(file.getAbsolutePath()); + profile.imageLoadFailed = profile.image == null; + return profile.image; + } + private View buildTabBar() { LinearLayout tabBar = new LinearLayout(activity); tabBar.setOrientation(LinearLayout.HORIZONTAL); @@ -469,6 +941,20 @@ public class MenuOverlay extends OverlayPanel { tabBar.addView(videoOptionsTabButton, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f)); + View separator2 = new View(activity); + separator2.setBackgroundColor(0xFFFFFFFF); + tabBar.addView(separator2, new LinearLayout.LayoutParams(2, ViewGroup.LayoutParams.MATCH_PARENT)); + + controllerOptionsTabButton = new Button(activity); + controllerOptionsTabButton.setText(Strings.get("controller_options", currentLanguage)); + controllerOptionsTabButton.setBackground(selectableButtonBackground()); + controllerOptionsTabButton.setTextColor(buttonTextColors()); + controllerOptionsTabButton.setOnClickListener(v -> switchTab(controllerOptionsTab)); + controllerOptionsTabButton.setOnTouchListener(CLICK_ON_TOUCH_UP); + registerHoverable(controllerOptionsTabButton); + tabBar.addView(controllerOptionsTabButton, + new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f)); + return tabBar; } @@ -476,16 +962,35 @@ public class MenuOverlay extends OverlayPanel { if (tab == currentTab) { return; } + // Defensive, per the Controller Options tab's own design - leaving + // this tab (however it happens) always cancels a pending capture, + // so it can never be silently left listening for a press that will + // now land on a row the player can't even see. + if (currentTab == controllerOptionsTab && capturingActionIndex != -1) { + nativeRequestCancelControlCapture(); + capturingActionIndex = -1; + } currentTab.setVisibility(View.GONE); tab.setVisibility(View.VISIBLE); currentTab = tab; mainMenuTabButton.setSelected(tab == mainMenuTab); videoOptionsTabButton.setSelected(tab == videoOptionsTab); + controllerOptionsTabButton.setSelected(tab == controllerOptionsTab); + nativeSetControllerOptionsTabOpen(tab == controllerOptionsTab); resetHover(); if (tab == videoOptionsTab) { refreshVideoOptionsSelection(); } else if (tab == mainMenuTab) { refreshLanguageSelection(); + } else if (tab == controllerOptionsTab) { + // Reset to the top only on actually switching into this tab - + // refreshControllerOptionsSelection() also runs on every + // periodic stats tick while it's the visible tab (see + // refreshStats()), which must never yank a mid-scroll view back + // to the top. + controllerScrollView.scrollTo(0, 0); + controllerScrollBar.setFraction(0f); + refreshControllerOptionsSelection(); } relayoutAndRedraw(); } @@ -649,6 +1154,8 @@ public class MenuOverlay extends OverlayPanel { refreshVideoOptionsSelection(); } else if (currentTab == mainMenuTab) { refreshLanguageSelection(); + } else if (currentTab == controllerOptionsTab) { + refreshControllerOptionsSelection(); } // The stats text's width changes every tick (digit count, "ms" @@ -702,6 +1209,20 @@ public class MenuOverlay extends OverlayPanel { gameScreenTitleView.setText(Strings.get("game_screen", currentLanguage)); gameScreenWidthLabel.setText(Strings.get("width", currentLanguage)); gameScreenDistanceLabel.setText(Strings.get("distance", currentLanguage)); + + controllerOptionsTabButton.setText(Strings.get("controller_options", currentLanguage)); + controllerGenericNoteView.setText(Strings.get("generic_profile_note", currentLanguage)); + controlResetButton.setText(Strings.get("reset_to_defaults", currentLanguage)); + for (int i = 0; i < ACTION_KEYS.length; i++) { + controlActionLabels[i].setText(Strings.get("action_" + ACTION_KEYS[i], currentLanguage)); + // The listening row's placeholder also needs re-translating on + // a language change mid-capture; every other row's actual + // binding text is refreshed by the very next periodic + // refreshControllerOptionsSelection() call regardless. + if (i == capturingActionIndex) { + controlBindingButtons[i].setText(Strings.get("capture_prompt", currentLanguage)); + } + } } // "Dismiss" on the restart dialog - the live-appliable subset was @@ -793,6 +1314,27 @@ public class MenuOverlay extends OverlayPanel { private static native float nativeGetCpuUtilization(); private static native float nativeGetGpuUtilization(); + // Controller Options tab - same enqueue-only reasoning as every other + // request_*() above. actionIndex indexes ACTION_KEYS (must match + // xr_input.c's own action ordering - see that array's own comment). + private static native void nativeRequestBeginControlCapture(int actionIndex); + private static native void nativeRequestCancelControlCapture(); + private static native void nativeRequestResetPlayerControls(); + // Read-only queries, safe to call directly - same reasoning as + // nativeGetRendererIsOpenGl() etc. above. nativeGetControlBindingKey() + // returns one of xr_input.c's kRawInputNames strings (e.g. "right_b", + // "trigger", "none") - rawInputDisplayName() maps it to a localized + // label. nativeGetActiveInteractionProfilePath() returns the OpenXR + // interaction profile path string (e.g. + // "/interaction_profiles/oculus/touch_controller"), or "" before the + // first successful in-engine query. + private static native String nativeGetControlBindingKey(int actionIndex); + private static native String nativeGetActiveInteractionProfilePath(); + // Tells xr_input.c when Controller Options is the visible tab, so the + // menu's thumbstick locomotion is off while bindings are being tested + // or reassigned - see switchTab(). + private static native void nativeSetControllerOptionsTabOpen(boolean open); + // Called from xr_overlay.c's xr_overlay_init_jni(), on the render // thread, right after it resolves this class via the activity's own // ClassLoader (plain FindClass() can't see app classes from a thread @@ -841,6 +1383,56 @@ public class MenuOverlay extends OverlayPanel { }); } + // Called from xr_input.c's xr_input_process_capture() (via + // qs_config_notify_control_capture_result(), qs_config.c) on the + // engine's own SDL thread, once a capture resolves - either a + // successful press (actionIndex is the row that was capturing) or a + // second call reporting a *different* row that got displaced by + // exclusivity (see xr_input.h's own comment), or a timeout/cancel + // (rawName null). Rather than applying rawName itself, this just clears + // capturingActionIndex (if it matches) and re-reads every row fresh + // from native truth - correct for either call shape with no need to + // special-case which one this is. + public static void onControlCaptureResult(final int actionIndex, final String rawName) { + if (instance == null) { + return; + } + instance.activity.runOnUiThread(() -> { + if (actionIndex == instance.capturingActionIndex) { + instance.capturingActionIndex = -1; + } + instance.refreshControllerOptionsSelection(); + }); + } + + // Called from qs_config.c's qs_config_report_fatal_input_error(), from + // inside xr_input_init() - which runs before this class's own + // nativeInit() (see xr_session.c: xr_input_init() happens before the + // menu/keyboard overlays' xr_overlay_create() calls), so `instance` is + // guaranteed still null here and can't be used to reach the Activity + // the way showRestartDialog()/showMessageDialog() do. SDLActivity's own + // getContext() is set from onCreate(), long before any of this native + // XR startup runs, so it's used instead - the one deliberate exception + // to this class's usual "everything hangs off `instance`" convention. + // Non-cancelable, single "Exit" button, same hard-kill pattern as + // restartApp() (Runtime.getRuntime().exit(0), not finish() - by the + // time this fires, native startup is already partway through, so only + // killing the whole process is guaranteed to actually stop it). + public static void showFatalControllerBindingError(final String message) { + Context context = org.libsdl.app.SDLActivity.getContext(); + if (!(context instanceof Activity)) { + return; + } + final Activity fatalActivity = (Activity) context; + fatalActivity.runOnUiThread(() -> new AlertDialog.Builder(fatalActivity) + .setTitle("Controller setup failed") + .setMessage(message) + .setCancelable(false) + .setPositiveButton("Exit", (dialog, which) -> Runtime.getRuntime().exit(0)) + .create() + .show()); + } + public static byte[] nativeTakePixelsIfDirty() { return instance == null ? null : instance.takePixelsIfDirty(); } diff --git a/android/app/src/main/java/de/ladkau/questshock/OverlayPanel.java b/android/app/src/main/java/de/ladkau/questshock/OverlayPanel.java index 6733d7b..d621f9e 100644 --- a/android/app/src/main/java/de/ladkau/questshock/OverlayPanel.java +++ b/android/app/src/main/java/de/ladkau/questshock/OverlayPanel.java @@ -271,7 +271,11 @@ abstract class OverlayPanel { // 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. + // 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()). @@ -285,7 +289,10 @@ abstract class OverlayPanel { if (current == root || !(current.getParent() instanceof View)) { break; } - current = (View) current.getParent(); + View parent = (View) current.getParent(); + left -= parent.getScrollX(); + top -= parent.getScrollY(); + current = parent; } return new Rect(left, top, left + view.getWidth(), top + view.getHeight()); } diff --git a/android/app/src/main/java/de/ladkau/questshock/QuestShockActivity.java b/android/app/src/main/java/de/ladkau/questshock/QuestShockActivity.java index 054bb17..6c41201 100644 --- a/android/app/src/main/java/de/ladkau/questshock/QuestShockActivity.java +++ b/android/app/src/main/java/de/ladkau/questshock/QuestShockActivity.java @@ -23,7 +23,7 @@ import org.libsdl.app.SDLSurface; * SDL_RWops, so Android's asset-manager fallback for SDL_RWFromFile * doesn't apply here) expects to find it - see android/engine-patches/ for * why this is done from Java instead of patching engine/ itself, which is - * never modified.q + * never modified. * * super.onCreate() (SDLActivity's) must always run first and unconditionally * - Android throws SuperNotCalledException otherwise, checked right after @@ -39,7 +39,8 @@ import org.libsdl.app.SDLSurface; public class QuestShockActivity extends SDLActivity { private static final String TAG = "QuestShock"; 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 // getLibraries()/loadLibraries(), which only runs from super.onCreate()) @@ -143,7 +144,7 @@ public class QuestShockActivity extends SDLActivity { // is already there, assume a previous run already extracted everything. File marker = new File(gameDir, "GET_ASSETS_QUEST.txt"); 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"), false); copyAssetFile("GET_ASSETS_QUEST.txt", marker, false); } @@ -155,6 +156,13 @@ public class QuestShockActivity extends SDLActivity { 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 // class doc above) - it just does plain fopen("res/data/...", ...) // and, on a fresh install with no game data copied in yet, that @@ -239,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(); String[] entries; try { @@ -253,7 +261,7 @@ public class QuestShockActivity extends SDLActivity { } destDir.mkdirs(); for (String entry : entries) { - copyAssetFile(assetDir + "/" + entry, new File(destDir, entry), false); + copyAssetFile(assetDir + "/" + entry, new File(destDir, entry), overwrite); } } } diff --git a/android/app/src/main/java/de/ladkau/questshock/Strings.java b/android/app/src/main/java/de/ladkau/questshock/Strings.java index 4b79040..fa29c2a 100644 --- a/android/app/src/main/java/de/ladkau/questshock/Strings.java +++ b/android/app/src/main/java/de/ladkau/questshock/Strings.java @@ -61,6 +61,106 @@ final class Strings { "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_toggle_freelook", "Toggle Free Look", "Basculer la vue libre", + "Freies Umsehen umschalten"); + 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"); + 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() {} diff --git a/android/engine-patches/23-android-intro-language.patch b/android/engine-patches/23-android-intro-language.patch new file mode 100644 index 0000000..a42b078 --- /dev/null +++ b/android/engine-patches/23-android-intro-language.patch @@ -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; diff --git a/build-image/prepare-android-project.sh b/build-image/prepare-android-project.sh index 1b01ce7..8ae235c 100755 --- a/build-image/prepare-android-project.sh +++ b/build-image/prepare-android-project.sh @@ -92,6 +92,7 @@ cp -a "$ANDROID_DIR/gles-shaders/." "$ASSETS_DIR/shaders/" cp "/opt/prebuilt/soundfont/default.sf2" "$ASSETS_DIR/res/soundfont.sf2" cp "$REPO_ROOT/res/assets/GET_ASSETS_QUEST.txt" "$ASSETS_DIR/GET_ASSETS_QUEST.txt" cp "$REPO_ROOT/res/questshock.config.default.json" "$ASSETS_DIR/questshock.config.default.json" +cp -a "$REPO_ROOT/res/controller_profiles" "$ASSETS_DIR/controller_profiles" 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 diff --git a/res/controller_profiles/generic.json b/res/controller_profiles/generic.json new file mode 100644 index 0000000..49a4bc1 --- /dev/null +++ b/res/controller_profiles/generic.json @@ -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} + ] +} diff --git a/res/controller_profiles/generic.png b/res/controller_profiles/generic.png new file mode 100644 index 0000000..1493fc5 Binary files /dev/null and b/res/controller_profiles/generic.png differ diff --git a/res/controller_profiles/touch.json b/res/controller_profiles/touch.json new file mode 100644 index 0000000..1a205a6 --- /dev/null +++ b/res/controller_profiles/touch.json @@ -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} + ] +} diff --git a/res/controller_profiles/touch.png b/res/controller_profiles/touch.png new file mode 100644 index 0000000..32480bc Binary files /dev/null and b/res/controller_profiles/touch.png differ diff --git a/res/controller_profiles/vive.json b/res/controller_profiles/vive.json new file mode 100644 index 0000000..6319407 --- /dev/null +++ b/res/controller_profiles/vive.json @@ -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} + ] +} diff --git a/res/controller_profiles/vive.png b/res/controller_profiles/vive.png new file mode 100644 index 0000000..6ddb821 Binary files /dev/null and b/res/controller_profiles/vive.png differ diff --git a/res/screenshots/gallery_016.jpg b/res/screenshots/gallery_016.jpg new file mode 100644 index 0000000..92b25a3 Binary files /dev/null and b/res/screenshots/gallery_016.jpg differ diff --git a/res/sketch_main_menu.svg b/res/sketch_main_menu.svg deleted file mode 100644 index bf3ff44..0000000 --- a/res/sketch_main_menu.svg +++ /dev/null @@ -1,132 +0,0 @@ - - - - - - - - - - Menu content here - Questshock (v3.0.2) - [FPS: 70] - - Main Menu - Video Options - - - -