#include "xr_input.h" #include #include #include #include #include #include "qs_config.h" #include "xr_mouse.h" #include "xr_overlay.h" #include "xr_session.h" #include "xr_swapchain.h" #define TAG "QuestShock" #define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__) #define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__) #define LEFT 0 #define RIGHT 1 // Same constant (deliberately not 's M_PI - see xr_overlay.c's own // copy of this comment) as xr_session.c's play-space rebuild and // xr_overlay.c's quad-rotation math - this file's ray/oriented-quad // intersection and thumbstick-turn math need the same conversion. #define QS_DEG_TO_RAD (3.14159265358979323846f / 180.0f) static const char *kHandName[2] = {"left", "right"}; static const float kHandColor[2][4] = { {0.2f, 1.0f, 1.0f, 1.0f}, // left: cyan {1.0f, 0.85f, 0.1f, 1.0f}, // right: amber }; // Both overlays' title bars (drag handle, plus the keyboard's Close button) // are handled entirely here, before a hit would normally be dispatched to // the Java side as a touch. A hit in the top *_TITLE_BAR_V_FRACTION of a // quad, at or left of its own drag-handle-U-fraction, is the drag handle; // right of that (still within the title bar) is dispatched as an ordinary // click instead - the keyboard's Close button, or (the menu has no such // button - MENU_DRAG_HANDLE_U_FRACTION is 1.0f, so its whole title bar // width is the drag handle) nothing. // // KEYBOARD_TITLE_BAR_V_FRACTION/KEYBOARD_CLOSE_BUTTON_U_FRACTION must agree // with KeyboardOverlay.java's TITLE_BAR_HEIGHT/CLOSE_BUTTON_WIDTH constants // (in pixels, out of KeyboardOverlay.WIDTH/HEIGHT there); // MENU_TITLE_BAR_V_FRACTION with MenuOverlay.java's HEADER_HEIGHT (out of // MenuOverlay.HEIGHT). #define KEYBOARD_TITLE_BAR_V_FRACTION (90.0f / 768.0f) #define KEYBOARD_CLOSE_BUTTON_U_FRACTION (1.0f - 160.0f / 1024.0f) #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, free view, a separate set // of cyberspace movement actions, 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_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_FREE_LEFT_THUMBSTICK_VIEW, QS_ACTION_FREE_RIGHT_THUMBSTICK_VIEW, QS_ACTION_CYBER_THRUST, QS_ACTION_CYBER_CLIMB, QS_ACTION_CYBER_DIVE, QS_ACTION_CYBER_BANK_LEFT, QS_ACTION_CYBER_BANK_RIGHT, QS_ACTION_CYBER_ROLL_LEFT, QS_ACTION_CYBER_ROLL_RIGHT, QS_ACTION_CYBER_CLIMB_LEFT, QS_ACTION_CYBER_CLIMB_RIGHT, QS_ACTION_CYBER_DIVE_LEFT, QS_ACTION_CYBER_DIVE_RIGHT, 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", "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", "free_left_thumbstick_view", "free_right_thumbstick_view", "cyber_thrust", "cyber_climb", "cyber_dive", "cyber_bank_left", "cyber_bank_right", "cyber_roll_left", "cyber_roll_right", "cyber_climb_left", "cyber_climb_right", "cyber_dive_left", "cyber_dive_right", }; // 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 opens the // automap; right A/B click/double-click; right trigger fires; left grip holds // free view on the left stick. In cyberspace: left stick climbs/dives and banks, // right stick thrusts (up) and rolls. 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_NONE, // pause QS_RAW_NONE, // full_view QS_RAW_NONE, // normal_view QS_RAW_RIGHT_THUMBSTICK_CLICK, // 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 QS_RAW_LEFT_SQUEEZE, // free_left_thumbstick_view QS_RAW_NONE, // free_right_thumbstick_view QS_RAW_RIGHT_THUMBSTICK_UP, // cyber_thrust QS_RAW_LEFT_THUMBSTICK_UP, // cyber_climb QS_RAW_LEFT_THUMBSTICK_DOWN, // cyber_dive QS_RAW_LEFT_THUMBSTICK_LEFT, // cyber_bank_left QS_RAW_LEFT_THUMBSTICK_RIGHT, // cyber_bank_right QS_RAW_RIGHT_THUMBSTICK_LEFT, // cyber_roll_left QS_RAW_RIGHT_THUMBSTICK_RIGHT, // cyber_roll_right QS_RAW_NONE, // cyber_climb_left QS_RAW_NONE, // cyber_climb_right QS_RAW_NONE, // cyber_dive_left QS_RAW_NONE, // cyber_dive_right }; // 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"/"pause" - DOWN('t'/'g'/'b'/'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_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}, // Cyberspace movement (Prefs.c's MoveCyberKeybinds defaults): S thrust, W climb, X dive, // A/D bank, E/Q roll, and the keypad 7/9/1/3 diagonal combinations. {QS_ACTION_CYBER_THRUST, SDL_SCANCODE_S, SDLK_s, KMOD_NONE, true, 's'}, {QS_ACTION_CYBER_CLIMB, SDL_SCANCODE_W, SDLK_w, KMOD_NONE, true, 'w'}, {QS_ACTION_CYBER_DIVE, SDL_SCANCODE_X, SDLK_x, KMOD_NONE, true, 'x'}, {QS_ACTION_CYBER_BANK_LEFT, SDL_SCANCODE_A, SDLK_a, KMOD_NONE, true, 'a'}, {QS_ACTION_CYBER_BANK_RIGHT, SDL_SCANCODE_D, SDLK_d, KMOD_NONE, true, 'd'}, {QS_ACTION_CYBER_ROLL_LEFT, SDL_SCANCODE_E, SDLK_e, KMOD_NONE, true, 'e'}, {QS_ACTION_CYBER_ROLL_RIGHT, SDL_SCANCODE_Q, SDLK_q, KMOD_NONE, true, 'q'}, {QS_ACTION_CYBER_CLIMB_LEFT, SDL_SCANCODE_KP_7, SDLK_KP_7, KMOD_NONE, false, 0}, {QS_ACTION_CYBER_CLIMB_RIGHT, SDL_SCANCODE_KP_9, SDLK_KP_9, KMOD_NONE, false, 0}, {QS_ACTION_CYBER_DIVE_LEFT, SDL_SCANCODE_KP_1, SDLK_KP_1, KMOD_NONE, false, 0}, {QS_ACTION_CYBER_DIVE_RIGHT, SDL_SCANCODE_KP_3, SDLK_KP_3, 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; // 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}; // Edge-detection state, so touch dispatch and logging only fire on actual // state changes, not every frame. Each interaction target (game quad, menu // launcher, keyboard) tracks its own "was this hand's ray hitting it last // 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; // 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]; // Raw analog stick/trackpad position per hand, for the free-view actions. static float g_stick_xy[2][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, // so holding the stick deflected turns exactly once, not once per frame. static bool g_turn_ready = true; // Self-contained delta-time tracker for smooth movement - deliberately not // shared with xr_session.c's own SDL_GetTicks()-based FPS smoothing, which // is a private implementation detail of that file's stats display. static Uint32 g_last_move_ticks = 0; // True until either xr_input_recenter_player_to_screen() has succeeded // once this session, or xr_input_skip_startup_recenter() has been called - // checked once per frame in xr_input_sync_and_draw() (see its own comment // there) as a *fallback* default player position/orientation for when // there's no complete saved one to fall back on instead (see // qs_config_load_startup()'s own comment on when it calls // xr_input_skip_startup_recenter()). While still pending, retries every // frame until a valid head pose is actually available - typically the // very first frame, but tracking isn't guaranteed to be valid that early. static bool g_pending_startup_recenter = true; static bool g_game_prev_hit[2] = {false, false}; static bool g_game_touch_active[2] = {false, false}; static bool g_menu_prev_hit[2] = {false, false}; static bool g_menu_touch_active[2] = {false, false}; static bool g_keyboard_prev_hit[2] = {false, false}; static bool g_keyboard_touch_active[2] = {false, false}; // Drag state for repositioning an overlay via its title bar (see the // *_TITLE_BAR_V_FRACTION/*_DRAG_HANDLE_U_FRACTION or // *_CLOSE_BUTTON_U_FRACTION defines above). hand is -1 when dragging is // false. offset_x/offset_y is the vector from the quad's center to the // grabbed point at drag-start, kept constant for the whole gesture so the // quad follows the hand rigidly rather than re-centering under the ray // each frame. Each draggable overlay (keyboard, menu) gets its own // instance so they can be dragged independently/simultaneously by // different hands. typedef struct { bool dragging; int hand; float offset_x; float offset_y; } XrDragState; static XrDragState g_keyboard_drag = {false, -1, 0.0f, 0.0f}; static XrDragState g_menu_drag = {false, -1, 0.0f, 0.0f}; // A "view" reference space, located once per frame (not per hand) to get // an approximate head position for the laser-beam billboard math below - // the only consumer of a head pose in this file. Both LOCAL (g_local_space, // owned by xr_session.c) and VIEW reference spaces are mandated by core // OpenXR, so no capability check is needed to create this. static XrSpace g_view_space = XR_NULL_HANDLE; // Per-hand laser-beam state: a thin, billboarded quad spanning from the // controller to wherever that hand's ray currently crosses the plane of // whichever target (keyboard, menu, or the game quad) claimed it this // frame - see xr_input_build_beam()/xr_input_get_beam_layer(). Each hand // gets its own tiny solid-color swapchain (no shared tint on XrCompositionLayerQuad, // so two separately-colored textures is simplest). g_beam_quad_valid is // reset to false at the top of every xr_input_sync_and_draw() call and // only set back to true if that hand actually claims a beam this frame. #define BEAM_TEX_SIZE 2 #define BEAM_THICKNESS_METERS 0.004f #define MAX_BEAM_LENGTH_METERS 2.0f static XrSwapchainState g_beam_swapchain[2]; static XrCompositionLayerQuad g_beam_quad[2]; static bool g_beam_quad_valid[2] = {false, false}; static bool xr_check(XrResult result, const char *what) { if (XR_SUCCEEDED(result)) return true; char resultString[XR_MAX_RESULT_STRING_SIZE] = {0}; if (g_instance != XR_NULL_HANDLE) xrResultToString(g_instance, result, resultString); LOGE("XR: %s failed: %s (%d)", what, resultString[0] ? resultString : "?", (int)result); return false; } // Standard quaternion-vector rotation (v + 2*cross(q.xyz, cross(q.xyz, v) + // q.w*v)) - used to turn the aim pose's orientation into its local -Z // ("forward") axis, the ray direction per the OpenXR aim-pose convention. static void quat_rotate_vec(const XrQuaternionf *q, float vx, float vy, float vz, float *outx, float *outy, float *outz) { float cx = q->y * vz - q->z * vy + q->w * vx; float cy = q->z * vx - q->x * vz + q->w * vy; float cz = q->x * vy - q->y * vx + q->w * vz; *outx = vx + 2.0f * (q->y * cz - q->z * cy); *outy = vy + 2.0f * (q->z * cx - q->x * cz); *outz = vz + 2.0f * (q->x * cy - q->y * cx); } static void vec3_sub(const float a[3], const float b[3], float out[3]) { out[0] = a[0] - b[0]; out[1] = a[1] - b[1]; out[2] = a[2] - b[2]; } static float vec3_dot(const float a[3], const float b[3]) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; } static void vec3_cross(const float a[3], const float b[3], float out[3]) { out[0] = a[1] * b[2] - a[2] * b[1]; out[1] = a[2] * b[0] - a[0] * b[2]; out[2] = a[0] * b[1] - a[1] * b[0]; } // Returns false (out left untouched) if v is too close to zero-length to // normalize safely - callers use this to detect a degenerate billboard // axis and fall back to another reference vector. static bool vec3_normalize(const float v[3], float out[3]) { float len = sqrtf(vec3_dot(v, v)); if (len < 1e-6f) return false; out[0] = v[0] / len; out[1] = v[1] / len; out[2] = v[2] / len; return true; } // Converts an orthonormal local->world rotation matrix (given as its // columns - x/y/z, each the world-space direction of that local axis) to // an XrQuaternionf, via the standard trace-based (Shepperd) method. static void mat3_to_quat(const float x[3], const float y[3], const float z[3], XrQuaternionf *q) { float m00 = x[0], m10 = x[1], m20 = x[2]; float m01 = y[0], m11 = y[1], m21 = y[2]; float m02 = z[0], m12 = z[1], m22 = z[2]; float trace = m00 + m11 + m22; if (trace > 0.0f) { float s = sqrtf(trace + 1.0f) * 2.0f; q->w = 0.25f * s; q->x = (m21 - m12) / s; q->y = (m02 - m20) / s; q->z = (m10 - m01) / s; } else if (m00 > m11 && m00 > m22) { float s = sqrtf(1.0f + m00 - m11 - m22) * 2.0f; q->w = (m21 - m12) / s; q->x = 0.25f * s; q->y = (m01 + m10) / s; q->z = (m02 + m20) / s; } else if (m11 > m22) { float s = sqrtf(1.0f + m11 - m00 - m22) * 2.0f; q->w = (m02 - m20) / s; q->x = (m01 + m10) / s; q->y = 0.25f * s; q->z = (m12 + m21) / s; } else { float s = sqrtf(1.0f + m22 - m00 - m11) * 2.0f; q->w = (m10 - m01) / s; q->x = (m02 + m20) / s; q->y = (m12 + m21) / s; q->z = 0.25f * s; } } // =========================================================================== // 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_stick_xy[hand][0] = state.isActive ? state.currentState.x : 0.0f; g_stick_xy[hand][1] = state.isActive ? state.currentState.y : 0.0f; } 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_NONE, // pause QS_RAW_NONE, // full_view QS_RAW_NONE, // normal_view QS_RAW_RIGHT_THUMBSTICK_CLICK, // 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 QS_RAW_LEFT_SQUEEZE, // free_left_thumbstick_view QS_RAW_NONE, // free_right_thumbstick_view QS_RAW_RIGHT_THUMBSTICK_UP, // cyber_thrust QS_RAW_LEFT_THUMBSTICK_UP, // cyber_climb QS_RAW_LEFT_THUMBSTICK_DOWN, // cyber_dive QS_RAW_LEFT_THUMBSTICK_LEFT, // cyber_bank_left QS_RAW_LEFT_THUMBSTICK_RIGHT, // cyber_bank_right QS_RAW_RIGHT_THUMBSTICK_LEFT, // cyber_roll_left QS_RAW_RIGHT_THUMBSTICK_RIGHT, // cyber_roll_right QS_RAW_NONE, // cyber_climb_left QS_RAW_NONE, // cyber_climb_right QS_RAW_NONE, // cyber_dive_left QS_RAW_NONE, // cyber_dive_right }; 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)); } // Where an action applies. NORMAL actions (walking, jumping, posture) are // inactive in cyberspace and CYBER actions only work there, so each set has its // own bindings and one input can be bound in both. Everything else (clicks, // fire, free view, hotkeys) applies everywhere. typedef enum { QS_CONTEXT_ALWAYS, QS_CONTEXT_NORMAL, QS_CONTEXT_CYBER } QsActionContext; static QsActionContext xr_input_action_context(QsControlAction action) { if (action >= QS_ACTION_CYBER_THRUST) return QS_CONTEXT_CYBER; switch (action) { case QS_ACTION_MOVE_FORWARD: case QS_ACTION_MOVE_BACK: case QS_ACTION_STRAFE_LEFT: case QS_ACTION_STRAFE_RIGHT: case QS_ACTION_TURN_LEFT: case QS_ACTION_TURN_RIGHT: case QS_ACTION_JUMP: case QS_ACTION_STAND: case QS_ACTION_CROUCH: case QS_ACTION_PRONE: return QS_CONTEXT_NORMAL; default: return QS_CONTEXT_ALWAYS; } } // True if two actions can be active at the same time, so one input shared by // both would fire both. static bool xr_input_contexts_overlap(QsControlAction a, QsControlAction b) { QsActionContext ca = xr_input_action_context(a), cb = xr_input_action_context(b); return ca == QS_CONTEXT_ALWAYS || cb == QS_CONTEXT_ALWAYS || ca == cb; } // 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 that can // be active together with it - see xr_input_contexts_overlap() - may respond // to a given raw input, see xr_input_binding_conflicts()) by clearing any // other such 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_contexts_overlap((QsControlAction)a, 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; xrStringToPath(instance, "/user/hand/left", &g_hand_path[LEFT]); xrStringToPath(instance, "/user/hand/right", &g_hand_path[RIGHT]); XrActionSetCreateInfo setInfo = {XR_TYPE_ACTION_SET_CREATE_INFO}; strncpy(setInfo.actionSetName, "gameplay", XR_MAX_ACTION_SET_NAME_SIZE - 1); strncpy(setInfo.localizedActionSetName, "Gameplay", XR_MAX_LOCALIZED_ACTION_SET_NAME_SIZE - 1); setInfo.priority = 0; if (!xr_check(xrCreateActionSet(instance, &setInfo, &g_action_set), "xrCreateActionSet")) return false; XrActionCreateInfo aimInfo = {XR_TYPE_ACTION_CREATE_INFO}; strncpy(aimInfo.actionName, "aim_pose", XR_MAX_ACTION_NAME_SIZE - 1); strncpy(aimInfo.localizedActionName, "Aim Pose", XR_MAX_LOCALIZED_ACTION_NAME_SIZE - 1); aimInfo.actionType = XR_ACTION_TYPE_POSE_INPUT; aimInfo.countSubactionPaths = 2; aimInfo.subactionPaths = g_hand_path; if (!xr_check(xrCreateAction(g_action_set, &aimInfo, &g_aim_pose_action), "xrCreateAction(aim_pose)")) return false; if (!xr_input_create_bool_action("select_click", "Select", 2, g_hand_path, &g_select_click_action)) return false; // 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; if (!xr_input_create_bool_action("a_click", "A Button", 1, &g_hand_path[RIGHT], &g_a_click_action)) return false; 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. XrActionCreateInfo thumbstickInfo = {XR_TYPE_ACTION_CREATE_INFO}; strncpy(thumbstickInfo.actionName, "thumbstick", XR_MAX_ACTION_NAME_SIZE - 1); strncpy(thumbstickInfo.localizedActionName, "Thumbstick", XR_MAX_LOCALIZED_ACTION_NAME_SIZE - 1); thumbstickInfo.actionType = XR_ACTION_TYPE_VECTOR2F_INPUT; thumbstickInfo.countSubactionPaths = 2; thumbstickInfo.subactionPaths = g_hand_path; if (!xr_check(xrCreateAction(g_action_set, &thumbstickInfo, &g_thumbstick_action), "xrCreateAction(thumbstick)")) return false; // 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. 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}, }; 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++) { XrActionSpaceCreateInfo spaceInfo = {XR_TYPE_ACTION_SPACE_CREATE_INFO}; spaceInfo.action = g_aim_pose_action; spaceInfo.subactionPath = g_hand_path[hand]; spaceInfo.poseInActionSpace.orientation.w = 1.0f; if (!xr_check(xrCreateActionSpace(session, &spaceInfo, &g_aim_space[hand]), "xrCreateActionSpace(aim)")) return false; } XrSessionActionSetsAttachInfo attachInfo = {XR_TYPE_SESSION_ACTION_SETS_ATTACH_INFO}; attachInfo.countActionSets = 1; attachInfo.actionSets = &g_action_set; if (!xr_check(xrAttachSessionActionSets(session, &attachInfo), "xrAttachSessionActionSets")) return false; XrReferenceSpaceCreateInfo viewSpaceInfo = {XR_TYPE_REFERENCE_SPACE_CREATE_INFO}; viewSpaceInfo.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_VIEW; viewSpaceInfo.poseInReferenceSpace.orientation.w = 1.0f; if (!xr_check(xrCreateReferenceSpace(session, &viewSpaceInfo, &g_view_space), "xrCreateReferenceSpace(view)")) return false; for (int hand = 0; hand < 2; hand++) { if (!xr_swapchain_create(instance, session, swapchain_format, BEAM_TEX_SIZE, BEAM_TEX_SIZE, &g_beam_swapchain[hand])) { LOGE("XR: beam swapchain setup failed for hand %d", hand); return false; } } LOGI("XR: input action set ready (Touch/Vive/generic bindings suggested, %d remappable " "actions)", (int)QS_ACTION_COUNT); return true; } // Hit-tests/dispatches this hand's ray against one overlay quad - shared // by the menu launcher and keyboard overlays in xr_input_sync_and_draw() // below. titleBarVFraction/dragHandleUFraction and dragState select the // per-overlay title-bar/drag-handle geometry and drag bookkeeping (see the // *_TITLE_BAR_V_FRACTION/*_DRAG_HANDLE_U_FRACTION/*_CLOSE_BUTTON_U_FRACTION // defines above and g_keyboard_drag/g_menu_drag) - both overlays are // draggable via their title bar, and any hit below titleBarVFraction is // always a plain click regardless of overlay. Returns true only when this // hand's ray is actually relevant to this overlay this frame - landing on // it now, or continuing a touch/drag begun on a previous frame for this // hand that hasn't been released yet - not merely because the overlay is // visible. The caller should only stop trying other targets (menu/ // keyboard/game quad) when this returns true; a visible-but-unclaimed // overlay falls through so a lower-priority target (ultimately the game // quad) can still be aimed at. static bool xr_input_try_overlay(XrOverlay *overlay, float titleBarVFraction, float dragHandleUFraction, XrDragState *dragState, int hand, const XrSpaceLocation *location, float fx, float fy, float fz, bool selectDownEdge, bool selectUpEdge, bool *touchActive, bool *prevHit, const char *quadName, float *outCursorU, float *outCursorV, bool *outCursorHit, bool *outPlaneHit, float *outPlaneDistance) { *outPlaneHit = false; if (!xr_overlay_is_visible(overlay)) return false; // Captured before this call's own down/up-edge handling below can // mutate them, so a hand with a pending down-touch (waiting for its // matching up) or an in-progress drag on *this* overlay still claims // the ray this frame even if it has strayed off the panel's current // bounds - see `claims` below. bool hadTouch = *touchActive; bool hadDrag = dragState->dragging && dragState->hand == hand; float quadCenterX, quadCenterY, distance, halfWidth, halfHeight, yawDeg; xr_overlay_get_quad_extent(overlay, &quadCenterX, &quadCenterY, &distance, &halfWidth, &halfHeight, &yawDeg); // General ray/oriented-quad intersection - the quad's plane passes // through (quadCenterX, quadCenterY, -distance) with normal // R_y(-yawDeg)*(0,0,1) (see xr_overlay.c's own pose-building comment on // the same rotation). Solved by rotating the ray's origin (relative to // the quad's center) and direction into the quad's own local frame via // R_y(+yawDeg) - the inverse of the render-time rotation - and // intersecting against the local z=0 plane there; A/B below are that // rotated frame's z-components of the (center-relative) origin and the // direction, so `t = -A/B` solves local_z(t) = A + t*B = 0. Degenerates // to exactly the old plain "z = -distance, no rotation" formula when // yawDeg is 0 (cosPhi=1, sinPhi=0) - true for every overlay except the // menu launcher right after xr_input_sync_and_draw() has re-centered it // in front of a turned player (see its own comment there). float phi = yawDeg * QS_DEG_TO_RAD; float cosPhi = cosf(phi), sinPhi = sinf(phi); float dx0 = location->pose.position.x - quadCenterX; float dz0 = location->pose.position.z + distance; float a = -dx0 * sinPhi + dz0 * cosPhi; float b = -fx * sinPhi + fz * cosPhi; // worldX/Y is the raw base-space intersection point, needed as-is for // dragging (which can legitimately move the quad to where the ray // isn't currently hitting it, and always operates in base-space X/Y via // xr_overlay_set_position() regardless of any rotation); cx/cy are that // same point in the quad's own [-1,1] local space; hit/u/v (the // equivalent 0..1, top-left-origin coordinates the Java view's pixel // grid and the on-quad hit logs use) are only meaningful within the // quad's current bounds, unlike planeHit. bool planeHit = false, hit = false; float u = 0.0f, v = 0.0f, worldX = 0.0f, worldY = 0.0f, t = 0.0f; if (fabsf(b) > 1e-5f) { t = -a / b; if (t > 0.0f) { planeHit = true; worldX = location->pose.position.x + t * fx; worldY = location->pose.position.y + t * fy; float worldZ = location->pose.position.z + t * fz; float localX = (worldX - quadCenterX) * cosPhi + (worldZ + distance) * sinPhi; float localY = worldY - quadCenterY; float cx = localX / halfWidth; float cy = localY / halfHeight; hit = fabsf(cx) <= 1.0f && fabsf(cy) <= 1.0f; u = (cx + 1.0f) * 0.5f; v = (1.0f - cy) * 0.5f; } } *outPlaneHit = planeHit; *outPlaneDistance = t; if (hit != *prevHit) { // Disabled for now - noisy while chasing an unrelated texture bug. // LOGI("XR: %s aim ray %s %s quad (u=%.2f v=%.2f)", kHandName[hand], // hit ? "entered" : "left", quadName, u, v); *prevHit = hit; } if (hit) { *outCursorHit = true; *outCursorU = u; *outCursorV = v; } // Only claim (and thus route/beam) this hand's ray to this overlay // when it's actually relevant this frame: landing on it now, or // continuing a touch/drag that started on it - never merely because // it's visible. bool claims = hit || hadTouch || hadDrag; bool onTitleBar = hit && v < titleBarVFraction; bool onDragHandle = onTitleBar && u <= dragHandleUFraction; if (selectDownEdge) { if (onDragHandle) { dragState->dragging = true; dragState->hand = hand; dragState->offset_x = worldX - quadCenterX; dragState->offset_y = worldY - quadCenterY; } else if (hit) { xr_overlay_touch(overlay, u, v, true); *touchActive = true; } } else if (selectUpEdge) { if (dragState->dragging && dragState->hand == hand) { dragState->dragging = false; dragState->hand = -1; } else if (*touchActive) { xr_overlay_touch(overlay, u, v, false); *touchActive = false; } } // Re-intersects against the same fixed-distance plane every frame the // drag continues, so the quad tracks the hand in real time rather than // only jumping on the down/up edges above. if (dragState->dragging && dragState->hand == hand && planeHit) { xr_overlay_set_position(overlay, worldX - dragState->offset_x, worldY - dragState->offset_y); } return claims; } // Builds this hand's laser-beam quad - a thin, billboarded ribbon from the // controller (location->pose.position) along the aim ray (fx,fy,fz, // already unit length) to length_m meters out, oriented so it reads as a // line from the viewer's eye (headLoc->pose.position) regardless of angle: // one in-plane axis follows the ray direction, the other is the thin // "width", and the quad's normal is billboarded toward the eye // (orthogonalized against the ray axis, so the ribbon only rotates around // its own long axis as the hand moves, never twists). Acquires/clears/ // releases this hand's tiny beam swapchain and leaves g_beam_quad[hand] // ready for xr_input_get_beam_layer() - doesn't set space/eyeVisibility/ // layerFlags (see that function's doc comment in xr_input.h). static void xr_input_build_beam(int hand, const XrSpaceLocation *location, float fx, float fy, float fz, float length_m, const XrSpaceLocation *headLoc) { float origin[3] = {location->pose.position.x, location->pose.position.y, location->pose.position.z}; float dirY[3] = {fx, fy, fz}; float endpoint[3] = {origin[0] + length_m * fx, origin[1] + length_m * fy, origin[2] + length_m * fz}; float center[3] = {(origin[0] + endpoint[0]) * 0.5f, (origin[1] + endpoint[1]) * 0.5f, (origin[2] + endpoint[2]) * 0.5f}; float head[3] = {headLoc->pose.position.x, headLoc->pose.position.y, headLoc->pose.position.z}; float toEyeRaw[3], toEye[3]; vec3_sub(head, center, toEyeRaw); if (!vec3_normalize(toEyeRaw, toEye)) { // Head is (almost) exactly at the beam's midpoint - astronomically // unlikely, but fall back to a fixed direction rather than divide // by ~0. toEye[0] = 0.0f; toEye[1] = 0.0f; toEye[2] = 1.0f; } // Orthogonalize toEye against the ray axis to get the billboard // normal - if the ray points almost straight at/away from the eye, // that leaves ~nothing to normalize, so fall back to world-up then // world-Z, each reprojected the same way. float normalZ[3]; float d = vec3_dot(toEye, dirY); float proj[3] = {toEye[0] - dirY[0] * d, toEye[1] - dirY[1] * d, toEye[2] - dirY[2] * d}; if (!vec3_normalize(proj, normalZ)) { static const float kWorldUp[3] = {0.0f, 1.0f, 0.0f}; d = vec3_dot(kWorldUp, dirY); proj[0] = kWorldUp[0] - dirY[0] * d; proj[1] = kWorldUp[1] - dirY[1] * d; proj[2] = kWorldUp[2] - dirY[2] * d; if (!vec3_normalize(proj, normalZ)) { static const float kWorldZ[3] = {0.0f, 0.0f, 1.0f}; d = vec3_dot(kWorldZ, dirY); proj[0] = kWorldZ[0] - dirY[0] * d; proj[1] = kWorldZ[1] - dirY[1] * d; proj[2] = kWorldZ[2] - dirY[2] * d; if (!vec3_normalize(proj, normalZ)) { // dirY parallel to both world-up and world-Z is impossible // for two non-parallel vectors - unreachable in practice, // but keep the beam well-defined regardless. normalZ[0] = 1.0f; normalZ[1] = 0.0f; normalZ[2] = 0.0f; } } } float axisX[3]; vec3_cross(dirY, normalZ, axisX); if (!vec3_normalize(axisX, axisX)) { axisX[0] = 1.0f; axisX[1] = 0.0f; axisX[2] = 0.0f; } float axisZ[3]; vec3_cross(axisX, dirY, axisZ); // already unit length - axisX/dirY are orthonormal XrQuaternionf orientation; mat3_to_quat(axisX, dirY, axisZ, &orientation); if (xr_swapchain_acquire(g_instance, &g_beam_swapchain[hand])) { // Premultiplied alpha (matches OverlayPanel.compositeAndPublish()'s // convention, and XR_COMPOSITION_LAYER_BLEND_TEXTURE_SOURCE_ALPHA_BIT's // assumption - see xr_session.c) - kHandColor's RGB scaled by this // beam's own alpha, not the opaque RGB itself. const float alpha = 0.55f; const float *c = kHandColor[hand]; glClearColor(c[0] * alpha, c[1] * alpha, c[2] * alpha, alpha); glClear(GL_COLOR_BUFFER_BIT); xr_swapchain_release(g_instance, &g_beam_swapchain[hand]); } XrCompositionLayerQuad *quad = &g_beam_quad[hand]; quad->type = XR_TYPE_COMPOSITION_LAYER_QUAD; quad->next = NULL; quad->subImage.swapchain = g_beam_swapchain[hand].swapchain; quad->subImage.imageRect.offset.x = 0; quad->subImage.imageRect.offset.y = 0; quad->subImage.imageRect.extent.width = BEAM_TEX_SIZE; quad->subImage.imageRect.extent.height = BEAM_TEX_SIZE; quad->subImage.imageArrayIndex = 0; quad->pose.position.x = center[0]; quad->pose.position.y = center[1]; quad->pose.position.z = center[2]; quad->pose.orientation = orientation; quad->size.width = BEAM_THICKNESS_METERS; quad->size.height = length_m; g_beam_quad_valid[hand] = true; } // Hit-tests this hand's ray against the game screen (xr_get_game_quad_extent()) // - the lowest-priority target, tried only once neither the keyboard nor // menu overlay claimed the ray this frame (or, symmetrically, once this // hand already has a pending mouse-down on the game screen - see // g_game_touch_active and the keyboard/menu call sites in // xr_input_sync_and_draw()). outU/outV (only meaningful when the // returned hit is true) let the caller drive the game's own mouse cursor // via xr_mouse.h. Returns plain `hit` - drag/touch dispatch for actual // clicks is the caller's responsibility (xr_mouse_click()), not this // function's, unlike xr_input_try_overlay(). // // Two shapes: a flat plane (centralAngle == 0, the default and the only // shape this function supported before screen curving existed - kept // byte-for-byte unchanged as its own branch below, so nobody who never // touches the new Video Options curve setting sees any change at all), or // a curved cylinder segment. The curve is horizontal-only (like a real // curved monitor) - the cylinder's vertical axis runs parallel to the // screen's own local Y at (x=0, z=Zc) for every y, so y drops out of the // lateral-surface intersection test entirely; halfHeight bounds the // vertical extent exactly like the flat case, unchanged. static bool xr_input_try_game_quad(int hand, const XrSpaceLocation *location, float fx, float fy, float fz, bool *outPlaneHit, float *outPlaneDistance, float *outU, float *outV) { float distance, halfWidth, halfHeight, radius, centralAngle; xr_get_game_quad_extent(&distance, &halfWidth, &halfHeight, &radius, ¢ralAngle); bool planeHit = false, hit = false; float u = 0.0f, v = 0.0f, t = 0.0f; if (centralAngle <= 0.0f) { if (fabsf(fz) > 1e-5f) { t = (-distance - location->pose.position.z) / fz; if (t > 0.0f) { planeHit = true; float cx = (location->pose.position.x + t * fx) / halfWidth; float cy = (location->pose.position.y + t * fy) / halfHeight; hit = fabsf(cx) <= 1.0f && fabsf(cy) <= 1.0f; u = (cx + 1.0f) * 0.5f; v = (1.0f - cy) * 0.5f; } } } else { // Zc: the cylinder's axis position along Z (see xr_session.c's // own concavity-derivation comment - same offset the render path // uses to place the actual XrCompositionLayerCylinderKHR, so the // two can never disagree about where the surface actually is). float Zc = -distance + radius; float x0 = location->pose.position.x; float dz0 = location->pose.position.z - Zc; // Ray-vs-infinite-cylinder quadratic (only x/z participate, per // this function's own comment above) - A<=~0 means the ray runs // parallel to the axis (never crosses the lateral surface), // mirroring the flat case's fabsf(fz)>1e-5f guard. float A = fx * fx + fz * fz; if (A > 1e-8f) { float B = 2.0f * (fx * x0 + fz * dz0); float C = x0 * x0 + dz0 * dz0 - radius * radius; float disc = B * B - 4.0f * A * C; if (disc >= 0.0f) { float sq = sqrtf(disc); // Smallest positive root - the near intersection, correct // both for the normal case (viewer inside the cylinder, // so exactly one root is positive) and if the viewer ever // ends up outside it. float t1 = (-B - sq) / (2.0f * A); float t2 = (-B + sq) / (2.0f * A); if (t1 > 0.0f) t = t1; else if (t2 > 0.0f) t = t2; if (t > 0.0f) { planeHit = true; float px = location->pose.position.x + t * fx; float py = location->pose.position.y + t * fy; float pz = location->pose.position.z + t * fz; // Signed angle from center (angle 0 is dead ahead, // matching the flat case's cx==0 being dead center) - // bounded by the configured centralAngle instead of a // halfWidth. float phi = atan2f(px, Zc - pz); float halfAngle = centralAngle * 0.5f; float cy = py / halfHeight; hit = fabsf(phi) <= halfAngle && fabsf(cy) <= 1.0f; u = (phi / halfAngle + 1.0f) * 0.5f; v = (1.0f - cy) * 0.5f; } } } } *outPlaneHit = planeHit; *outPlaneDistance = t; *outU = u; *outV = v; if (hit != g_game_prev_hit[hand]) { // Disabled for now - noisy while chasing an unrelated texture bug. // LOGI("XR: %s aim ray %s game quad (u=%.2f v=%.2f)", kHandName[hand], // hit ? "entered" : "left", u, v); g_game_prev_hit[hand] = hit; } return hit; } // Re-centers the menu launcher in front of wherever the player is // currently looking, each time it's freshly opened (see the menu-toggle // handling below) - otherwise a player who's walked/turned away from the // menu's last position (via the thumbstick locomotion further down, or a // manual drag) would find it stuck somewhere behind/beside them. Uses // xr_overlay_get_default_distance_m() (the overlay's original, never- // mutated distance_m) rather than duplicating MENU_DISTANCE_METERS here - // deliberately NOT the live distance_m from xr_overlay_get_quad_extent(), // which xr_overlay_set_pose() repurposes as a raw base-space Z coordinate // once rotation is involved (see that function's own comment) - reusing // that as a "how far in front" magnitude would drift the menu further // away with every reopen instead of holding a stable distance. Height // (center_y_m) doesn't have this problem (it's never touched by any // rotation math), so that one *is* read live, preserving a manual drag. // Independent of needBeams/draw below (a menu toggle can happen on a frame // nothing renders), so this does its own dedicated head locate rather than // reusing headLoc. static void xr_input_recenter_menu(XrOverlay *menuOverlay, XrSpace baseSpace, XrTime time) { XrSpaceLocation headLoc = {XR_TYPE_SPACE_LOCATION}; const XrSpaceLocationFlags needed = XR_SPACE_LOCATION_POSITION_VALID_BIT | XR_SPACE_LOCATION_ORIENTATION_VALID_BIT; if (!xr_check(xrLocateSpace(g_view_space, baseSpace, time, &headLoc), "xrLocateSpace(view, menu recenter)") || (headLoc.locationFlags & needed) != needed) return; float fx, fy, fz; quat_rotate_vec(&headLoc.pose.orientation, 0.0f, 0.0f, -1.0f, &fx, &fy, &fz); // Horizontal-only, matching the play space's own yaw-only rotation - // looking sharply up/down shouldn't tip the menu with you. float len = sqrtf(fx * fx + fz * fz); if (len < 1e-4f) return; // looking almost straight up/down - leave the existing pose alone fx /= len; fz /= len; float currentX, currentY, liveDistance, halfWidth, halfHeight, currentYaw; xr_overlay_get_quad_extent(menuOverlay, ¤tX, ¤tY, &liveDistance, &halfWidth, &halfHeight, ¤tYaw); (void)currentX; (void)liveDistance; (void)halfWidth; (void)halfHeight; (void)currentYaw; float distance = xr_overlay_get_default_distance_m(menuOverlay); float newX = headLoc.pose.position.x + fx * distance; float newZ = headLoc.pose.position.z + fz * distance; // xr_overlay_set_pose()'s distance_m is simply the quad's base-space Z // position negated (see xr_overlay_render_and_build_layer()) - not // necessarily positive once the quad can end up anywhere the player // has walked to, unlike every other overlay's fixed, always-in-front- // of-the-origin spawn pose. float newDistance = -newZ; // See xr_overlay_render_and_build_layer()'s own rotation comment - // solved so the quad's local front normal (0,0,1) ends up pointing // along -forward, i.e. back at the player looking along +forward at it. float newYawDeg = -atan2f(-fx, -fz) / QS_DEG_TO_RAD; xr_overlay_set_pose(menuOverlay, newX, currentY, newDistance, newYawDeg); } // See xr_input.h's own comment. Chooses a new player transform such that // the player's CURRENT real head pose maps to play-space position (0,0) // facing (0,0,-1) - i.e. exactly reproducing this app's own startup // framing (the fixed game quad dead ahead at its authored distance), just // re-anchored to wherever the player is standing/facing right now instead // of wherever their headset happened to be when the session first began. // Deliberately locates the head against the REAL local space (via // xr_session_get_local_space()), not the play space every other consumer // in this file uses - computing a brand new transform from the player's // real pose would be circular if measured through the very transform // being replaced. // // Math: with V(p) = R_y(yaw_deg) * p + (x, z) defined as "real-space pose p // maps to this play-space pose" (see xr_session_rebuild_play_space()'s own // derivation, which builds the inverse of this same V), solving // V(headPos) = (0,0) and V(headForward) = (0,0,-1) for (x, z, yaw_deg) // gives the formulas below - verified by substitution that a // 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. // // 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; XrTime time = xr_session_get_predicted_display_time(); XrSpaceLocation headLoc = {XR_TYPE_SPACE_LOCATION}; const XrSpaceLocationFlags needed = XR_SPACE_LOCATION_POSITION_VALID_BIT | XR_SPACE_LOCATION_ORIENTATION_VALID_BIT; if (!xr_check(xrLocateSpace(g_view_space, localSpace, time, &headLoc), "xrLocateSpace(view, recenter to screen)") || (headLoc.locationFlags & needed) != needed) return false; 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)). 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); float newZ = hx * sinTheta - hz * cosTheta; float newYawDeg = theta / QS_DEG_TO_RAD; xr_session_set_player_transform(newX, newZ, newYawDeg); 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; } // Free view: while a free_{left,right}_thumbstick_view action's button is // held (and the menu is closed), that hand's stick looks around instead of // driving its normal bindings. It feeds the engine's own mouse look // (mouselook.c): mlook_enabled is forced on for as long as a free view is // held, and the stick adds to MouseChaosX/Y, the extra look velocity that // get_mouselook_vel() (sdl_events.c) consumes once per physics tick. // get_mouselook_vel() returns zero while SDL considers the cursor shown, so // the cursor is kept hidden for the duration. extern int mlook_enabled; extern int qs_in_cyberspace(void); // engine patch 24 (mouselook.c) extern unsigned char game_paused; extern int MouseChaosX, MouseChaosY; #define QS_FREE_VIEW_DEADZONE 0.15f #define QS_FREE_VIEW_YAW_PER_SEC 500.0f #define QS_FREE_VIEW_PITCH_PER_SEC 250.0f static bool g_free_view_forced = false; // true while we have mlook_enabled forced on static int g_free_view_saved_mlook = 0; // mlook_enabled before that static float g_free_view_acc[2]; // fractional look counts not yet emitted static int g_free_view_saved_cursor = 0; // SDL_ShowCursor() state before it static Uint32 g_free_view_last_ticks = 0; static Uint32 g_free_view_log_ticks = 0; static void xr_input_apply_free_view(const bool active[2]) { Uint32 now = SDL_GetTicks(); float dt = g_free_view_last_ticks ? (float)(now - g_free_view_last_ticks) / 1000.0f : 0.0f; g_free_view_last_ticks = now; if (dt > 0.1f) dt = 0.1f; if (!active[LEFT] && !active[RIGHT]) { if (g_free_view_forced) { mlook_enabled = g_free_view_saved_mlook; SDL_ShowCursor(g_free_view_saved_cursor ? SDL_ENABLE : SDL_DISABLE); g_free_view_forced = false; g_free_view_acc[0] = g_free_view_acc[1] = 0.0f; } return; } if (!g_free_view_forced) { g_free_view_saved_mlook = mlook_enabled; g_free_view_saved_cursor = SDL_ShowCursor(SDL_QUERY); g_free_view_forced = true; } mlook_enabled = 1; SDL_ShowCursor(SDL_DISABLE); if (game_paused) return; float x = 0.0f, y = 0.0f; for (int hand = 0; hand < 2; hand++) { if (active[hand]) { x += g_stick_xy[hand][0]; y += g_stick_xy[hand][1]; } } float mag = sqrtf(x * x + y * y); if (mag > QS_FREE_VIEW_DEADZONE) { float scaled = (mag - QS_FREE_VIEW_DEADZONE) / (1.0f - QS_FREE_VIEW_DEADZONE); if (scaled > 1.0f) scaled = 1.0f; // Quadratic response: fine control near the centre, full rate at the edge. float factor = scaled * scaled / mag; x *= factor; y *= factor; } else { x = y = 0.0f; } // Stick right = look right; stick up = look up (mouse motion up is negative y). g_free_view_acc[0] += x * QS_FREE_VIEW_YAW_PER_SEC * dt; g_free_view_acc[1] += -y * QS_FREE_VIEW_PITCH_PER_SEC * dt; int dx = (int)g_free_view_acc[0]; int dy = (int)g_free_view_acc[1]; g_free_view_acc[0] -= (float)dx; g_free_view_acc[1] -= (float)dy; // Bounded so a held stick can't pile up unconsumed velocity when the // engine isn't running physics (e.g. outside gameplay). if (MouseChaosX > -4096 && MouseChaosX < 4096) MouseChaosX += dx; if (MouseChaosY > -4096 && MouseChaosY < 4096) MouseChaosY += dy; if (now - g_free_view_log_ticks > 500) { g_free_view_log_ticks = now; LOGI("XR: free view mlook=%d cursorShown=%d paused=%d stick=(%.2f,%.2f) chaos=(%d,%d)", mlook_enabled, SDL_ShowCursor(SDL_QUERY), (int)game_paused, x, y, MouseChaosX, MouseChaosY); } } // Cyberspace pitch and bank are full-strength on/off keys in the engine. For an // action bound to a thumbstick direction, engine patch 25 (movekeys.c) scales // that strength by these globals, set every frame from how far the stick is // pushed: MIN just past the digital threshold up to MAX at full deflection. A // button-bound action keeps full strength. extern float qs_cyber_pitch_scale; extern float qs_cyber_bank_scale; #define QS_CYBER_ROTATION_MIN_SCALE 0.2f #define QS_CYBER_ROTATION_MAX_SCALE 0.6f // Deflection (0..1) of the stick direction `raw` names, or -1 if `raw` is not a // thumbstick direction. The 8 direction inputs are ordered up/down/left/right // per hand. static float xr_input_stick_direction_deflection(QsRawInput raw) { if (!xr_input_raw_is_thumbstick_direction(raw)) return -1.0f; const float *xy = g_stick_xy[xr_input_raw_input_hand(raw)]; float d; switch ((raw - QS_RAW_LEFT_THUMBSTICK_UP) % 4) { case 0: d = xy[1]; break; case 1: d = -xy[1]; break; case 2: d = -xy[0]; break; default: d = xy[0]; break; } return d < 0.0f ? 0.0f : (d > 1.0f ? 1.0f : d); } static float xr_input_cyber_rotation_scale(QsRawInput raw) { float d = xr_input_stick_direction_deflection(raw); if (d < 0.0f) return 1.0f; float t = (d - 0.5f) / 0.5f; t = t < 0.0f ? 0.0f : (t > 1.0f ? 1.0f : t); return QS_CYBER_ROTATION_MIN_SCALE + (QS_CYBER_ROTATION_MAX_SCALE - QS_CYBER_ROTATION_MIN_SCALE) * t; } // Sets the two globals from the cyberspace pitch/bank actions currently held. static void xr_input_update_cyber_scales(const bool wantDown[]) { float pitchScale = 1.0f, bankScale = 1.0f; bool havePitch = false, haveBank = false; for (size_t i = 0; i < QS_NUM_KEY_ACTIONS; i++) { QsControlAction a = kKeyActions[i].action; bool pitch = a == QS_ACTION_CYBER_CLIMB || a == QS_ACTION_CYBER_DIVE || a == QS_ACTION_CYBER_CLIMB_LEFT || a == QS_ACTION_CYBER_CLIMB_RIGHT || a == QS_ACTION_CYBER_DIVE_LEFT || a == QS_ACTION_CYBER_DIVE_RIGHT; bool bank = a == QS_ACTION_CYBER_BANK_LEFT || a == QS_ACTION_CYBER_BANK_RIGHT || a == QS_ACTION_CYBER_CLIMB_LEFT || a == QS_ACTION_CYBER_CLIMB_RIGHT || a == QS_ACTION_CYBER_DIVE_LEFT || a == QS_ACTION_CYBER_DIVE_RIGHT; if (!wantDown[i] || (!pitch && !bank)) continue; float scale = xr_input_cyber_rotation_scale(s_binding[a]); if (pitch) { pitchScale = havePitch ? fmaxf(pitchScale, scale) : scale; havePitch = true; } if (bank) { bankScale = haveBank ? fmaxf(bankScale, scale) : scale; haveBank = true; } } qs_cyber_pitch_scale = pitchScale; qs_cyber_bank_scale = bankScale; } void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw) { if (g_action_set == XR_NULL_HANDLE) return; XrActiveActionSet activeSet = {g_action_set, XR_NULL_PATH}; XrActionsSyncInfo syncInfo = {XR_TYPE_ACTIONS_SYNC_INFO}; syncInfo.countActiveActionSets = 1; 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. if (g_pending_startup_recenter && xr_input_recenter_player_to_screen()) g_pending_startup_recenter = false; XrOverlay *menuOverlay = xr_session_get_menu_overlay(); 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(); // A free-view action's button held: that hand's stick looks around and // its own direction bindings are suspended (see xr_input_apply_free_view()). bool freeView[2]; for (int hand = 0; hand < 2; hand++) { QsControlAction viewAction = hand == LEFT ? QS_ACTION_FREE_LEFT_THUMBSTICK_VIEW : QS_ACTION_FREE_RIGHT_THUMBSTICK_VIEW; QsRawInput viewRaw = s_binding[viewAction]; freeView[hand] = viewRaw < QS_RAW_CONCRETE_COUNT && !menuVisible && !xr_input_action_suppressed_by_capture(viewAction) && g_raw_down[viewRaw]; } bool inCyber = qs_in_cyberspace() != 0; 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]; QsActionContext context = xr_input_action_context(keyAction->action); bool contextActive = context == QS_CONTEXT_ALWAYS || (context == QS_CONTEXT_CYBER) == inCyber; wantDown[i] = raw != QS_RAW_NONE && contextActive && !xr_input_action_suppressed_by_capture(keyAction->action) && !(xr_input_raw_is_thumbstick_direction(raw) && (menuVisible || freeView[xr_input_raw_input_hand(raw)])) && g_raw_down[raw]; } xr_input_update_cyber_scales(wantDown); xr_input_apply_free_view(freeView); 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}, // Cyberspace movement keys (letters and keypad, movekeys.h's CODE_*). {SDLK_w, 0x0D}, {SDLK_a, 0x00}, {SDLK_s, 0x01}, {SDLK_d, 0x02}, {SDLK_q, 0x0C}, {SDLK_e, 0x0E}, {SDLK_x, 0x07}, {SDLK_KP_7, 0x59}, {SDLK_KP_9, 0x5C}, {SDLK_KP_1, 0x53}, {SDLK_KP_3, 0x55}, }; 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. if (!keyboardVisible) { g_keyboard_drag.dragging = false; g_keyboard_drag.hand = -1; } if (!menuVisible) { g_menu_drag.dragging = false; g_menu_drag.hand = -1; } // A hand only ever gets a laser-beam quad this frame if it actually // claims the keyboard or menu overlay's ray below (see the // xr_input_build_beam() calls) - reset here so a hand that doesn't // claim one this frame doesn't keep showing last frame's beam. g_beam_quad_valid[LEFT] = g_beam_quad_valid[RIGHT] = false; // The billboard math needs an approximate head position - only bother // locating it on frames where a beam could possibly be drawn at all // (i.e. any drawn frame - the game quad can claim a beam on its own // even with both overlays closed). bool needBeams = draw; XrSpaceLocation headLoc = {XR_TYPE_SPACE_LOCATION}; bool haveHead = false; if (needBeams) { const XrSpaceLocationFlags neededHead = XR_SPACE_LOCATION_POSITION_VALID_BIT | XR_SPACE_LOCATION_ORIENTATION_VALID_BIT; haveHead = xr_check(xrLocateSpace(g_view_space, baseSpace, time, &headLoc), "xrLocateSpace(view)") && (headLoc.locationFlags & neededHead) == neededHead; } // Fed to xr_overlay_update_cursor() after the loop below - whichever // hand's ray hits a given overlay last wins if both do, good enough // for a single on-quad cursor per overlay (only ever updated when hit // is true, so a later hand that misses doesn't hide an earlier hand's // hit). bool menuCursorHit = false, keyboardCursorHit = false; float menuCursorU = 0.0f, menuCursorV = 0.0f; float keyboardCursorU = 0.0f, keyboardCursorV = 0.0f; for (int hand = 0; hand < 2; hand++) { // 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 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; menuInfo.subactionPath = g_hand_path[LEFT]; XrActionStateBoolean menuState = {XR_TYPE_ACTION_STATE_BOOLEAN}; xrGetActionStateBoolean(g_session, &menuInfo, &menuState); bool menuDown = menuState.isActive && menuState.currentState; if (menuDown && !g_prev_menu) { LOGI("XR: menu-toggle button DOWN"); // Only ever affects the menu launcher - the keyboard is // fully independent (its own Close button is the only way // to hide it, see KeyboardOverlay.java). if (menuOverlay != NULL) { bool wasHidden = !menuVisible; 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)) { 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"); } g_prev_menu = menuDown; // Snap turn - only while the menu is open (per the feature's // own design: repositioning is for lining yourself up with the // game quad from the menu, not a general gameplay locomotion // 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. 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]; XrActionStateVector2f turnState = {XR_TYPE_ACTION_STATE_VECTOR2F}; xrGetActionStateVector2f(g_session, &turnInfo, &turnState); if (turnState.isActive) { const float kSnapThreshold = 0.7f; const float kSnapRearm = 0.3f; const float kSnapDegrees = 30.0f; float x = turnState.currentState.x; if (g_turn_ready && fabsf(x) > kSnapThreshold) { float px, pz, pyaw; xr_session_get_player_transform(&px, &pz, &pyaw); // Confirmed backwards on-device from the initial // (unverifiable-without-a-headset) guess - flipped. pyaw += (x > 0.0f) ? -kSnapDegrees : kSnapDegrees; xr_session_set_player_transform(px, pz, pyaw); g_turn_ready = false; } else if (fabsf(x) < kSnapRearm) { g_turn_ready = true; } } } } if (hand == RIGHT) { // 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 && !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]; XrActionStateVector2f moveState = {XR_TYPE_ACTION_STATE_VECTOR2F}; xrGetActionStateVector2f(g_session, &moveInfo, &moveState); const float kMoveDeadzone = 0.15f; float mx = moveState.currentState.x, my = moveState.currentState.y; float mag = sqrtf(mx * mx + my * my); if (moveState.isActive && mag > kMoveDeadzone) { XrSpaceLocation headLoc = {XR_TYPE_SPACE_LOCATION}; const XrSpaceLocationFlags neededHead2 = XR_SPACE_LOCATION_POSITION_VALID_BIT | XR_SPACE_LOCATION_ORIENTATION_VALID_BIT; if (xr_check(xrLocateSpace(g_view_space, baseSpace, time, &headLoc), "xrLocateSpace(view, move)") && (headLoc.locationFlags & neededHead2) == neededHead2) { float hfx, hfy, hfz; quat_rotate_vec(&headLoc.pose.orientation, 0.0f, 0.0f, -1.0f, &hfx, &hfy, &hfz); float horizLen = sqrtf(hfx * hfx + hfz * hfz); if (horizLen > 1e-4f) { hfx /= horizLen; hfz /= horizLen; // "Right" relative to forward, in the horizontal // plane - R_y(-90deg) applied to (hfx, hfz), see // xr_overlay.c's own rotation-matrix comment for // the same R_y() convention. float rfx = -hfz, rfz = hfx; const float kMoveSpeedMetersPerSec = 1.2f; float dt = (float)(nowTicks - lastMoveTicks) / 1000.0f; float moveX = (hfx * my + rfx * mx) * kMoveSpeedMetersPerSec * dt; float moveZ = (hfz * my + rfz * mx) * kMoveSpeedMetersPerSec * dt; float px, pz, pyaw; xr_session_get_player_transform(&px, &pz, &pyaw); // Moves the play space opposite the player's // intended travel, so quads (fixed in play // space) get closer/farther as the player // virtually walks toward/away from them - see // xr_session_set_player_transform()'s own // comment. Confirmed backwards on-device from // the initial (unverifiable-without-a-headset) // guess - flipped. xr_session_set_player_transform(px + moveX, pz + moveZ, pyaw); } } } } } XrActionStateGetInfo poseInfo = {XR_TYPE_ACTION_STATE_GET_INFO}; poseInfo.action = g_aim_pose_action; poseInfo.subactionPath = g_hand_path[hand]; XrActionStatePose poseState = {XR_TYPE_ACTION_STATE_POSE}; xrGetActionStatePose(g_session, &poseInfo, &poseState); if (!poseState.isActive) continue; XrSpaceLocation location = {XR_TYPE_SPACE_LOCATION}; if (!xr_check(xrLocateSpace(g_aim_space[hand], baseSpace, time, &location), "xrLocateSpace")) continue; const XrSpaceLocationFlags needed = XR_SPACE_LOCATION_POSITION_VALID_BIT | XR_SPACE_LOCATION_ORIENTATION_VALID_BIT; if ((location.locationFlags & needed) != needed) continue; float fx, fy, fz; quat_rotate_vec(&location.pose.orientation, 0.0f, 0.0f, -1.0f, &fx, &fy, &fz); // Keyboard first (it's the more likely target while it's up), then // the menu launcher, then - only once neither actually claims the // ray this frame (not merely "isn't visible" - see // xr_input_try_overlay()'s doc comment) - the game quad itself. // Whichever target claims the ray gets a laser-beam quad built for // it (if a head pose is available and the ray actually crosses // that target's plane) - see xr_input_build_beam(). Keyboard/menu // are additionally gated on !g_game_touch_active[hand]: a pending // mouse-down on the game quad (trigger still held since a // down-edge dispatched there) must keep claiming the ray even if // it strays onto another panel before release, the cross-target // analogue of xr_input_try_overlay()'s own hadTouch/hadDrag - the // eventual mouse-up has to reach the game quad, not whatever the // ray happens to be over on the release frame. bool keyboardPlaneHit = false; float keyboardPlaneDistance = 0.0f; if (!g_game_touch_active[hand] && xr_input_try_overlay(keyboardOverlay, KEYBOARD_TITLE_BAR_V_FRACTION, KEYBOARD_CLOSE_BUTTON_U_FRACTION, &g_keyboard_drag, hand, &location, fx, fy, fz, selectDownEdge, selectUpEdge, &g_keyboard_touch_active[hand], &g_keyboard_prev_hit[hand], "keyboard", &keyboardCursorU, &keyboardCursorV, &keyboardCursorHit, &keyboardPlaneHit, &keyboardPlaneDistance)) { if (haveHead && keyboardPlaneHit) xr_input_build_beam(hand, &location, fx, fy, fz, fminf(keyboardPlaneDistance, MAX_BEAM_LENGTH_METERS), &headLoc); continue; } bool menuPlaneHit = false; float menuPlaneDistance = 0.0f; if (!g_game_touch_active[hand] && xr_input_try_overlay(menuOverlay, MENU_TITLE_BAR_V_FRACTION, MENU_DRAG_HANDLE_U_FRACTION, &g_menu_drag, hand, &location, fx, fy, fz, selectDownEdge, selectUpEdge, &g_menu_touch_active[hand], &g_menu_prev_hit[hand], "menu", &menuCursorU, &menuCursorV, &menuCursorHit, &menuPlaneHit, &menuPlaneDistance)) { if (haveHead && menuPlaneHit) xr_input_build_beam(hand, &location, fx, fy, fz, fminf(menuPlaneDistance, MAX_BEAM_LENGTH_METERS), &headLoc); continue; } // Lowest priority (for a fresh hit): the game quad itself - drives // the engine's own mouse cursor/clicks via xr_mouse.h, exactly // like a desktop mouse (absolute position + left button), plus the // same laser-beam visual the other two targets get. bool gamePlaneHit = false; float gamePlaneDistance = 0.0f, gameU = 0.0f, gameV = 0.0f; bool gameHit = xr_input_try_game_quad(hand, &location, fx, fy, fz, &gamePlaneHit, &gamePlaneDistance, &gameU, &gameV); if (gameHit) { int gameWidth, gameHeight; xr_get_game_resolution(&gameWidth, &gameHeight); int px = (int)(gameU * (float)gameWidth); int py = (int)(gameV * (float)gameHeight); if (px < 0) px = 0; else if (px >= gameWidth) px = gameWidth - 1; if (py < 0) py = 0; else if (py >= gameHeight) py = gameHeight - 1; // Mouse look keeps the engine's cursor centred; don't fight it. if (!freeView[LEFT] && !freeView[RIGHT]) xr_mouse_move(px, py); } if (selectDownEdge && gameHit) { xr_mouse_click(true); g_game_touch_active[hand] = true; } else if (selectUpEdge && g_game_touch_active[hand]) { 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. if (haveHead && gameHit) xr_input_build_beam(hand, &location, fx, fy, fz, fminf(gamePlaneDistance, MAX_BEAM_LENGTH_METERS), &headLoc); } if (menuVisible) xr_overlay_update_cursor(menuOverlay, menuCursorU, menuCursorV, menuCursorHit); 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) { if (hand < 0 || hand > 1 || !g_beam_quad_valid[hand]) return false; *out_quad = g_beam_quad[hand]; return true; } void xr_input_shutdown(void) { if (g_free_view_forced) { mlook_enabled = g_free_view_saved_mlook; SDL_ShowCursor(g_free_view_saved_cursor ? SDL_ENABLE : SDL_DISABLE); g_free_view_forced = false; } // 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]); g_aim_space[hand] = XR_NULL_HANDLE; xr_swapchain_destroy(&g_beam_swapchain[hand]); } if (g_view_space != XR_NULL_HANDLE) xrDestroySpace(g_view_space); g_view_space = XR_NULL_HANDLE; if (g_action_set != XR_NULL_HANDLE) xrDestroyActionSet(g_action_set); g_action_set = XR_NULL_HANDLE; g_aim_pose_action = XR_NULL_HANDLE; g_select_click_action = XR_NULL_HANDLE; g_menu_toggle_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)); memset(g_menu_touch_active, 0, sizeof(g_menu_touch_active)); memset(g_keyboard_prev_hit, 0, sizeof(g_keyboard_prev_hit)); 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_keyboard_drag.dragging = false; g_keyboard_drag.hand = -1; g_menu_drag.dragging = false; g_menu_drag.hand = -1; g_turn_ready = true; g_last_move_ticks = 0; g_pending_startup_recenter = true; }