diff --git a/game_patch/CMakeLists.txt b/game_patch/CMakeLists.txt index 28b576673..41d7ef0b2 100644 --- a/game_patch/CMakeLists.txt +++ b/game_patch/CMakeLists.txt @@ -97,8 +97,9 @@ set(SRCS graphics/d3d11/gr_d3d11_hooks.cpp graphics/d3d11/gr_d3d11_hooks.h input/input.h - input/mouse.h + input/input.cpp input/mouse.cpp + input/mouse.h input/key.cpp hud/hud_colors.cpp hud/hud_scale.cpp @@ -328,4 +329,13 @@ target_link_libraries(AlpineFaction ws2_32 freetype stb_vorbis + SDL3::SDL3 ) + +if(WIN32) + target_link_libraries(AlpineFaction + setupapi + imm32 + cfgmgr32 + ) +endif() \ No newline at end of file diff --git a/game_patch/input/input.cpp b/game_patch/input/input.cpp new file mode 100644 index 000000000..7a5dba342 --- /dev/null +++ b/game_patch/input/input.cpp @@ -0,0 +1,13 @@ +// Central SDL event pump for all input subsystems. +#include +#include "input.h" +#include "../misc/alpine_settings.h" + +void sdl_input_poll() +{ + if (SDL_IsMainThread()) + SDL_PumpEvents(); + if (g_alpine_game_config.input_mode == 2) + keyboard_sdl_poll(); + mouse_sdl_poll(); +} diff --git a/game_patch/input/input.h b/game_patch/input/input.h index 347b0badb..044bc6e03 100644 --- a/game_patch/input/input.h +++ b/game_patch/input/input.h @@ -1,8 +1,38 @@ #pragma once #include "../rf/player/control_config.h" +#include "mouse.h" + +// Sentinel scan code injected into Input Rebind UI, allowing additional input bindings. +static constexpr int CTRL_REBIND_SENTINEL = 0x58; // KEY_F12 + +// Extra keyboard scan codes not in RF's original DInput table. +// Placed after CTRL_EXTRA_MOUSE_SCAN (0x75–0x79), before RF extended keys (0x9C+). +static constexpr int CTRL_EXTRA_KEY_SCAN_BASE = 0x7A; +static constexpr int CTRL_EXTRA_KEY_SCAN_COUNT = 14; + +enum ExtraKeyScanOffset : int { + EXTRA_KEY_KP_DIVIDE = 0, // SDL_SCANCODE_KP_DIVIDE + EXTRA_KEY_NONUSBACKSLASH, // SDL_SCANCODE_NONUSBACKSLASH (ISO key between LShift and Z) + EXTRA_KEY_F13, // SDL_SCANCODE_F13 … F24 + EXTRA_KEY_F14, + EXTRA_KEY_F15, + EXTRA_KEY_F16, + EXTRA_KEY_F17, + EXTRA_KEY_F18, + EXTRA_KEY_F19, + EXTRA_KEY_F20, + EXTRA_KEY_F21, + EXTRA_KEY_F22, + EXTRA_KEY_F23, + EXTRA_KEY_F24, +}; rf::ControlConfigAction get_af_control(rf::AlpineControlConfigAction alpine_control); rf::String get_action_bind_name(int action); -void mouse_apply_patch(); +void keyboard_sdl_poll(); +int key_take_pending_extra_rebind(); +void sdl_input_poll(); void key_apply_patch(); +void set_input_mode(int mode); +void ui_refresh_input_mode_label(); diff --git a/game_patch/input/key.cpp b/game_patch/input/key.cpp index 15ac17891..440c46c5a 100644 --- a/game_patch/input/key.cpp +++ b/game_patch/input/key.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -11,6 +12,7 @@ #include "../misc/waypoints_utils.h" #include "../multi/multi.h" #include "../multi/endgame_votes.h" +#include "input.h" #include "../rf/input.h" #include "../rf/entity.h" #include "../rf/multi.h" @@ -19,10 +21,12 @@ #include "../rf/player/player.h" #include "../rf/os/console.h" #include "../rf/os/os.h" +#include "../rf/ui.h" #include "../multi/alpine_packets.h" #include "../os/console.h" static int starting_alpine_control_index = -1; +static int g_pending_extra_key_rebind = -1; // pending scan code for sentinel rebind pattern rf::String get_action_bind_name(int action) { @@ -45,80 +49,334 @@ rf::ControlConfigAction get_af_control(rf::AlpineControlConfigAction alpine_cont return static_cast(starting_alpine_control_index + static_cast(alpine_control)); } +static SDL_Scancode rf_key_to_sdl_scancode(int key); // defined below + FunHook key_to_ascii_hook{ 0x0051EFC0, [](int16_t key) { using namespace rf; constexpr int empty_result = 0xFF; - if (!key) { + if (!key) return empty_result; - } - // special handling for Num Lock (because ToAscii API does not support it) + + // Numpad arithmetic keys: same in all modes switch (key & KEY_MASK) { - // Numpad keys that always work case KEY_PADMULTIPLY: return static_cast('*'); - case KEY_PADMINUS: return static_cast('-'); - case KEY_PADPLUS: return static_cast('+'); - // Disable Numpad Enter key because game is not prepared for getting new line character from this function - case KEY_PADENTER: return empty_result; + case KEY_PADMINUS: return static_cast('-'); + case KEY_PADPLUS: return static_cast('+'); + case KEY_PADENTER: return empty_result; // game not prepared for newline from numpad + case CTRL_EXTRA_KEY_SCAN_BASE + EXTRA_KEY_KP_DIVIDE: return static_cast('/'); } - if (GetKeyState(VK_NUMLOCK) & 1) { - switch (key & KEY_MASK) { - case KEY_PAD7: return static_cast('7'); - case KEY_PAD8: return static_cast('8'); - case KEY_PAD9: return static_cast('9'); - case KEY_PAD4: return static_cast('4'); - case KEY_PAD5: return static_cast('5'); - case KEY_PAD6: return static_cast('6'); - case KEY_PAD1: return static_cast('1'); - case KEY_PAD2: return static_cast('2'); - case KEY_PAD3: return static_cast('3'); - case KEY_PAD0: return static_cast('0'); - case KEY_PADPERIOD: return static_cast('.'); + + if (g_alpine_game_config.input_mode == 2) { + // SDL keyboard mode + SDL_Keymod sdl_mods = SDL_GetModState(); + + // Treat pure Ctrl shortcuts as non-text, but allow AltGr (often Ctrl+Alt or SDL_KMOD_MODE) + if (key & KEY_CTRLED) { + bool is_altgr = + (sdl_mods & SDL_KMOD_MODE) != 0 || + ((sdl_mods & SDL_KMOD_CTRL) && (sdl_mods & SDL_KMOD_ALT)); + if (!is_altgr) + return empty_result; } + + if (sdl_mods & SDL_KMOD_NUM) { + switch (key & KEY_MASK) { + case KEY_PAD7: return static_cast('7'); + case KEY_PAD8: return static_cast('8'); + case KEY_PAD9: return static_cast('9'); + case KEY_PAD4: return static_cast('4'); + case KEY_PAD5: return static_cast('5'); + case KEY_PAD6: return static_cast('6'); + case KEY_PAD1: return static_cast('1'); + case KEY_PAD2: return static_cast('2'); + case KEY_PAD3: return static_cast('3'); + case KEY_PAD0: return static_cast('0'); + case KEY_PADPERIOD: return static_cast('.'); + } + } + + SDL_Scancode sc = rf_key_to_sdl_scancode(key); + if (sc == SDL_SCANCODE_UNKNOWN) + return empty_result; + + SDL_Keymod mods = SDL_KMOD_NONE; + if (key & KEY_SHIFTED) mods |= SDL_KMOD_SHIFT; + if (key & KEY_ALTED) mods |= SDL_KMOD_RALT; // AltGr on most non-US layouts + + SDL_Keycode kc = SDL_GetKeyFromScancode(sc, mods, false); + if (kc == SDLK_UNKNOWN || kc < 0x20 || kc > 0x7E) + return empty_result; + + return static_cast(kc); + } else { + // Legacy/DirectInput keyboard modes (0 and 1): use Win32 APIs + // Note: broken on Wine with non-US layout (MAPVK_VSC_TO_VK_EX mapping issue) + if (GetKeyState(VK_NUMLOCK) & 1) { + switch (key & KEY_MASK) { + case KEY_PAD7: return static_cast('7'); + case KEY_PAD8: return static_cast('8'); + case KEY_PAD9: return static_cast('9'); + case KEY_PAD4: return static_cast('4'); + case KEY_PAD5: return static_cast('5'); + case KEY_PAD6: return static_cast('6'); + case KEY_PAD1: return static_cast('1'); + case KEY_PAD2: return static_cast('2'); + case KEY_PAD3: return static_cast('3'); + case KEY_PAD0: return static_cast('0'); + case KEY_PADPERIOD: return static_cast('.'); + } + } + BYTE key_state[256] = {0}; + if (key & KEY_SHIFTED) key_state[VK_SHIFT] = 0x80; + if (key & KEY_ALTED) key_state[VK_MENU] = 0x80; + if (key & KEY_CTRLED) key_state[VK_CONTROL] = 0x80; + int scan_code = key & 0x7F; + auto vk = MapVirtualKeyA(scan_code, MAPVK_VSC_TO_VK); + WCHAR unicode_chars[3]; + auto num_unicode_chars = ToUnicode(vk, scan_code, key_state, unicode_chars, std::size(unicode_chars), 0); + if (num_unicode_chars < 1) + return empty_result; + if (static_cast(unicode_chars[0]) >= 0x80 || !std::isprint(unicode_chars[0])) + return empty_result; + return static_cast(unicode_chars[0]); } - BYTE key_state[256] = {0}; - if (key & KEY_SHIFTED) { - key_state[VK_SHIFT] = 0x80; - } - if (key & KEY_ALTED) { - key_state[VK_MENU] = 0x80; - } - if (key & KEY_CTRLED) { - key_state[VK_CONTROL] = 0x80; - } - int scan_code = key & 0x7F; - auto vk = MapVirtualKeyA(scan_code, MAPVK_VSC_TO_VK); - WCHAR unicode_chars[3]; - auto num_unicode_chars = ToUnicode(vk, scan_code, key_state, unicode_chars, std::size(unicode_chars), 0); - if (num_unicode_chars < 1) { - return empty_result; - } - char ansi_char; -#if 0 // Windows-1252 codepage support - disabled because callers of this function expects ASCII - int num_ansi_chars = WideCharToMultiByte(1252, 0, unicode_chars, num_unicode_chars, - &ansi_char, sizeof(ansi_char), nullptr, nullptr); - if (num_ansi_chars == 0) { - return empty_result; - } -#else - if (static_cast(unicode_chars[0]) >= 0x80 || !std::isprint(unicode_chars[0])) { - return empty_result; - } - ansi_char = static_cast(unicode_chars[0]); -#endif - xlog::trace("vk {:x} ({}) char {}", vk, vk, ansi_char); - return static_cast(ansi_char); }, }; +static SDL_Scancode rf_key_to_sdl_scancode(int key) +{ + using namespace rf; + switch (key & KEY_MASK) { + case KEY_ESC: return SDL_SCANCODE_ESCAPE; + case KEY_1: return SDL_SCANCODE_1; + case KEY_2: return SDL_SCANCODE_2; + case KEY_3: return SDL_SCANCODE_3; + case KEY_4: return SDL_SCANCODE_4; + case KEY_5: return SDL_SCANCODE_5; + case KEY_6: return SDL_SCANCODE_6; + case KEY_7: return SDL_SCANCODE_7; + case KEY_8: return SDL_SCANCODE_8; + case KEY_9: return SDL_SCANCODE_9; + case KEY_0: return SDL_SCANCODE_0; + case KEY_MINUS: return SDL_SCANCODE_MINUS; + case KEY_EQUAL: return SDL_SCANCODE_EQUALS; + case KEY_BACKSP: return SDL_SCANCODE_BACKSPACE; + case KEY_TAB: return SDL_SCANCODE_TAB; + case KEY_Q: return SDL_SCANCODE_Q; + case KEY_W: return SDL_SCANCODE_W; + case KEY_E: return SDL_SCANCODE_E; + case KEY_R: return SDL_SCANCODE_R; + case KEY_T: return SDL_SCANCODE_T; + case KEY_Y: return SDL_SCANCODE_Y; + case KEY_U: return SDL_SCANCODE_U; + case KEY_I: return SDL_SCANCODE_I; + case KEY_O: return SDL_SCANCODE_O; + case KEY_P: return SDL_SCANCODE_P; + case KEY_LBRACKET: return SDL_SCANCODE_LEFTBRACKET; + case KEY_RBRACKET: return SDL_SCANCODE_RIGHTBRACKET; + case KEY_ENTER: return SDL_SCANCODE_RETURN; + case KEY_LCTRL: return SDL_SCANCODE_LCTRL; + case KEY_A: return SDL_SCANCODE_A; + case KEY_S: return SDL_SCANCODE_S; + case KEY_D: return SDL_SCANCODE_D; + case KEY_F: return SDL_SCANCODE_F; + case KEY_G: return SDL_SCANCODE_G; + case KEY_H: return SDL_SCANCODE_H; + case KEY_J: return SDL_SCANCODE_J; + case KEY_K: return SDL_SCANCODE_K; + case KEY_L: return SDL_SCANCODE_L; + case KEY_SEMICOL: return SDL_SCANCODE_SEMICOLON; + case KEY_RAPOSTRO: return SDL_SCANCODE_APOSTROPHE; + case KEY_LAPOSTRO_DBG: return SDL_SCANCODE_GRAVE; + case KEY_LSHIFT: return SDL_SCANCODE_LSHIFT; + case KEY_SLASH: return SDL_SCANCODE_BACKSLASH; + case KEY_Z: return SDL_SCANCODE_Z; + case KEY_X: return SDL_SCANCODE_X; + case KEY_C: return SDL_SCANCODE_C; + case KEY_V: return SDL_SCANCODE_V; + case KEY_B: return SDL_SCANCODE_B; + case KEY_N: return SDL_SCANCODE_N; + case KEY_M: return SDL_SCANCODE_M; + case KEY_COMMA: return SDL_SCANCODE_COMMA; + case KEY_PERIOD: return SDL_SCANCODE_PERIOD; + case KEY_DIVIDE: return SDL_SCANCODE_SLASH; + case KEY_RSHIFT: return SDL_SCANCODE_RSHIFT; + case KEY_PADMULTIPLY: return SDL_SCANCODE_KP_MULTIPLY; + case KEY_LALT: return SDL_SCANCODE_LALT; + case KEY_SPACEBAR: return SDL_SCANCODE_SPACE; + case KEY_CAPSLOCK: return SDL_SCANCODE_CAPSLOCK; + case KEY_F1: return SDL_SCANCODE_F1; + case KEY_F2: return SDL_SCANCODE_F2; + case KEY_F3: return SDL_SCANCODE_F3; + case KEY_F4: return SDL_SCANCODE_F4; + case KEY_F5: return SDL_SCANCODE_F5; + case KEY_F6: return SDL_SCANCODE_F6; + case KEY_F7: return SDL_SCANCODE_F7; + case KEY_F8: return SDL_SCANCODE_F8; + case KEY_F9: return SDL_SCANCODE_F9; + case KEY_F10: return SDL_SCANCODE_F10; + case KEY_PAUSE: return SDL_SCANCODE_PAUSE; + case KEY_SCROLLLOCK: return SDL_SCANCODE_SCROLLLOCK; + case KEY_PAD7: return SDL_SCANCODE_KP_7; + case KEY_PAD8: return SDL_SCANCODE_KP_8; + case KEY_PAD9: return SDL_SCANCODE_KP_9; + case KEY_PADMINUS: return SDL_SCANCODE_KP_MINUS; + case KEY_PAD4: return SDL_SCANCODE_KP_4; + case KEY_PAD5: return SDL_SCANCODE_KP_5; + case KEY_PAD6: return SDL_SCANCODE_KP_6; + case KEY_PADPLUS: return SDL_SCANCODE_KP_PLUS; + case KEY_PAD1: return SDL_SCANCODE_KP_1; + case KEY_PAD2: return SDL_SCANCODE_KP_2; + case KEY_PAD3: return SDL_SCANCODE_KP_3; + case KEY_PAD0: return SDL_SCANCODE_KP_0; + case KEY_PADPERIOD: return SDL_SCANCODE_KP_PERIOD; + case KEY_F11: return SDL_SCANCODE_F11; + case KEY_F12: return SDL_SCANCODE_F12; + case KEY_PADENTER: return SDL_SCANCODE_KP_ENTER; + case KEY_RCTRL: return SDL_SCANCODE_RCTRL; + case KEY_PRINT_SCRN: return SDL_SCANCODE_PRINTSCREEN; + case KEY_RALT: return SDL_SCANCODE_RALT; + case KEY_NUMLOCK: return SDL_SCANCODE_NUMLOCKCLEAR; + case KEY_BREAK: return SDL_SCANCODE_PAUSE; + case KEY_HOME: return SDL_SCANCODE_HOME; + case KEY_UP: return SDL_SCANCODE_UP; + case KEY_PAGEUP: return SDL_SCANCODE_PAGEUP; + case KEY_LEFT: return SDL_SCANCODE_LEFT; + case KEY_RIGHT: return SDL_SCANCODE_RIGHT; + case KEY_END: return SDL_SCANCODE_END; + case KEY_DOWN: return SDL_SCANCODE_DOWN; + case KEY_PAGEDOWN: return SDL_SCANCODE_PAGEDOWN; + case KEY_INSERT: return SDL_SCANCODE_INSERT; + case KEY_DELETE: return SDL_SCANCODE_DELETE; + // Extra keyboard keys (custom scan codes not in RF's original DInput table) + case CTRL_EXTRA_KEY_SCAN_BASE + EXTRA_KEY_KP_DIVIDE: return SDL_SCANCODE_KP_DIVIDE; + case CTRL_EXTRA_KEY_SCAN_BASE + EXTRA_KEY_NONUSBACKSLASH: return SDL_SCANCODE_NONUSBACKSLASH; + case CTRL_EXTRA_KEY_SCAN_BASE + EXTRA_KEY_F13: return SDL_SCANCODE_F13; + case CTRL_EXTRA_KEY_SCAN_BASE + EXTRA_KEY_F14: return SDL_SCANCODE_F14; + case CTRL_EXTRA_KEY_SCAN_BASE + EXTRA_KEY_F15: return SDL_SCANCODE_F15; + case CTRL_EXTRA_KEY_SCAN_BASE + EXTRA_KEY_F16: return SDL_SCANCODE_F16; + case CTRL_EXTRA_KEY_SCAN_BASE + EXTRA_KEY_F17: return SDL_SCANCODE_F17; + case CTRL_EXTRA_KEY_SCAN_BASE + EXTRA_KEY_F18: return SDL_SCANCODE_F18; + case CTRL_EXTRA_KEY_SCAN_BASE + EXTRA_KEY_F19: return SDL_SCANCODE_F19; + case CTRL_EXTRA_KEY_SCAN_BASE + EXTRA_KEY_F20: return SDL_SCANCODE_F20; + case CTRL_EXTRA_KEY_SCAN_BASE + EXTRA_KEY_F21: return SDL_SCANCODE_F21; + case CTRL_EXTRA_KEY_SCAN_BASE + EXTRA_KEY_F22: return SDL_SCANCODE_F22; + case CTRL_EXTRA_KEY_SCAN_BASE + EXTRA_KEY_F23: return SDL_SCANCODE_F23; + case CTRL_EXTRA_KEY_SCAN_BASE + EXTRA_KEY_F24: return SDL_SCANCODE_F24; + default: return SDL_SCANCODE_UNKNOWN; + } +} + +static rf::Key sdl_scancode_to_rf_key(SDL_Scancode sc) +{ + static rf::Key table[SDL_SCANCODE_COUNT] = {}; + static bool built = false; + if (!built) { + for (int k = 1; k <= static_cast(rf::KEY_MASK); ++k) { + SDL_Scancode mapped = rf_key_to_sdl_scancode(k); + if (mapped != SDL_SCANCODE_UNKNOWN && table[mapped] == rf::KEY_NONE) { + table[mapped] = static_cast(k); + } + } + built = true; + } + if (sc == SDL_SCANCODE_UNKNOWN || static_cast(sc) >= SDL_SCANCODE_COUNT) + return rf::KEY_NONE; + return table[static_cast(sc)]; +} + +// Returns true for scan codes that RF's rebind UI ignores (extra keys + Alt). +static bool scan_needs_rebind_sentinel(int scan) +{ + if (scan >= CTRL_EXTRA_KEY_SCAN_BASE && + scan < CTRL_EXTRA_KEY_SCAN_BASE + CTRL_EXTRA_KEY_SCAN_COUNT) + return true; + return scan == rf::KEY_LALT || scan == rf::KEY_RALT; +} + +void keyboard_sdl_poll() +{ + SDL_Event events[16]; + int n; + while ((n = SDL_PeepEvents(events, static_cast(std::size(events)), + SDL_GETEVENT, SDL_EVENT_KEY_DOWN, SDL_EVENT_TEXT_EDITING_CANDIDATES)) > 0) { + if (g_alpine_game_config.input_mode != 2) + continue; // drain without processing; Win32 keyboard handles input in modes 0/1 + for (int i = 0; i < n; ++i) { + const auto& evt = events[i]; + if (evt.type != SDL_EVENT_KEY_DOWN && evt.type != SDL_EVENT_KEY_UP) + continue; // only key state changes are relevant; discard text editing events + if (evt.key.repeat) + continue; // ignore OS key repeat; RF tracks state itself + const bool down = (evt.type == SDL_EVENT_KEY_DOWN); + const rf::Key rf_key = sdl_scancode_to_rf_key(evt.key.scancode); + if (rf_key == rf::KEY_NONE) + continue; + + int scan = static_cast(rf_key); + // Keys that RF's rebind UI can't handle use the sentinel pattern + if (scan_needs_rebind_sentinel(scan)) { + if (down && g_pending_extra_key_rebind < 0 && + rf::ui::options_controls_waiting_for_key) { + g_pending_extra_key_rebind = scan; + rf::key_process_event(CTRL_REBIND_SENTINEL, 1, 0); + } else { + rf::key_process_event(scan, down ? 1 : 0, 0); + } + } else { + rf::key_process_event(scan, down ? 1 : 0, 0); + } + } + } +} + +int key_take_pending_extra_rebind() +{ + int sc = g_pending_extra_key_rebind; + g_pending_extra_key_rebind = -1; + return sc; +} + int get_key_name(int key, char* buf, size_t buf_len) { - LONG lparam = (key & 0x7F) << 16; + // Extra mouse buttons (Mouse 4+) + if (key >= CTRL_EXTRA_MOUSE_SCAN_BASE && key < CTRL_EXTRA_MOUSE_SCAN_BASE + CTRL_EXTRA_MOUSE_SCAN_COUNT) { + int n = std::snprintf(buf, buf_len, "Mouse %d", (key - CTRL_EXTRA_MOUSE_SCAN_BASE) + 4); + return n > 0 ? n : 0; + } + // Extra keyboard keys + if (key >= CTRL_EXTRA_KEY_SCAN_BASE && key < CTRL_EXTRA_KEY_SCAN_BASE + CTRL_EXTRA_KEY_SCAN_COUNT) { + static const char* names[] = { + "Keypad /", "ISO \\", + "F13", "F14", "F15", "F16", "F17", "F18", + "F19", "F20", "F21", "F22", "F23", "F24", + }; + int n = std::snprintf(buf, buf_len, "%s", names[key - CTRL_EXTRA_KEY_SCAN_BASE]); + return n > 0 ? n : 0; + } + if (g_alpine_game_config.input_mode == 2) { + // SDL mode: use SDL key names + SDL_Scancode sc = rf_key_to_sdl_scancode(key); + if (sc == SDL_SCANCODE_UNKNOWN) { + buf[0] = '\0'; + return 0; + } + const char* name = SDL_GetKeyName(SDL_GetKeyFromScancode(sc, SDL_KMOD_NONE, false)); + if (!name || name[0] == '\0') { + buf[0] = '\0'; + return 0; + } + SDL_strlcpy(buf, name, buf_len); + return static_cast(SDL_strlen(name)); + } + // Modes 0/1: use Win32 key names + // Note: it seems broken on Wine with non-US layout due to MAPVK_VSC_TO_VK_EX mapping + LONG lparam = (key & 0x7F) << 16; if (key & 0x80) { lparam |= 1 << 24; } - // Note: it seems broken on Wine with non-US layout (most likely broken MAPVK_VSC_TO_VK_EX mapping is responsible) int ret = GetKeyNameTextA(lparam, buf, buf_len); if (ret <= 0) { WARN_ONCE("GetKeyNameTextA failed for 0x{:X}", key); @@ -495,15 +753,30 @@ FunHook key_msg_handler_hook{ case WM_KEYDOWN: case WM_SYSKEYDOWN: case WM_KEYUP: - case WM_SYSKEYUP: { - // For num pads, RF requires `KF_EXTENDED` to be set. - if (w_param == VK_PRIOR - || w_param == VK_NEXT - || w_param == VK_END - || w_param == VK_HOME) { + case WM_SYSKEYUP: + if (g_alpine_game_config.input_mode == 2 + && (SDL_WasInit(SDL_INIT_VIDEO) & SDL_INIT_VIDEO) != 0 + && SDL_GetKeyboardFocus() != nullptr) + return; // SDL handles keyboard in mode 2 + + // RF requires KF_EXTENDED for numpad-derived navigation keys + if (w_param == VK_PRIOR || w_param == VK_NEXT + || w_param == VK_END || w_param == VK_HOME) l_param |= KF_EXTENDED << 16; + + // Sentinel pattern for keys RF's rebind UI rejects (Alt, extra keys) + if ((msg == WM_KEYDOWN || msg == WM_SYSKEYDOWN) + && rf::ui::options_controls_waiting_for_key + && g_pending_extra_key_rebind < 0) { + int win32_scan = (l_param >> 16) & 0x1FF; + int rf_scan = (win32_scan & 0x7F) | ((win32_scan & 0x100) ? 0x80 : 0); + if (scan_needs_rebind_sentinel(rf_scan)) { + g_pending_extra_key_rebind = rf_scan; + rf::key_process_event(CTRL_REBIND_SENTINEL, 1, 0); + return; + } } - } + break; } key_msg_handler_hook.call_target(msg, w_param, l_param); }, @@ -537,6 +810,7 @@ void key_apply_patch() // Support suppress autoswitch bind item_touch_weapon_autoswitch_patch.install(); - // Num pads need a patch to support `PgUp`, `PgDown`, `End`, and `Home`. + // Route keyboard events: Win32 WM_KEY* messages for modes 0/1 (with numpad KF_EXTENDED fix), + // blocked for mode 2 where SDL feeds keyboard events via keyboard_sdl_poll. key_msg_handler_hook.install(); -} +} \ No newline at end of file diff --git a/game_patch/input/mouse.cpp b/game_patch/input/mouse.cpp index d3d00336d..3636967ab 100644 --- a/game_patch/input/mouse.cpp +++ b/game_patch/input/mouse.cpp @@ -3,6 +3,8 @@ #include #include #include +#include +#include "input.h" #include "../os/console.h" #include "../rf/input.h" #include "../rf/entity.h" @@ -11,21 +13,32 @@ #include "../rf/multi.h" #include "../rf/player/player.h" #include "../rf/player/camera.h" +#include "../rf/ui.h" #include "../misc/alpine_settings.h" #include "../main/main.h" #include "mouse.h" #include "../multi/multi.h" -#include "input.h" -// Raw mouse delta accumulators for centralized camera angle computation. -static int g_camera_mouse_dx = 0, g_camera_mouse_dy = 0; +// SDL window and mouse motion state (used in SDL input mode only) +static SDL_Window* g_sdl_window = nullptr; +static bool g_relative_mouse_mode_window_missing_logged = false; +static float g_sdl_mouse_dx_rem = 0.0f, g_sdl_mouse_dy_rem = 0.0f; +static float g_sdl_mouse_wheel_rem = 0.0f; // sub-notch accumulator for smooth-scroll devices +static int g_sdl_mouse_dx = 0, g_sdl_mouse_dy = 0; + +// Extra mouse button rebind state (Mouse 4-8, used with SDL input mode) +static int g_pending_mouse_extra_btn_rebind = -1; static bool is_freelook_camera() { return rf::local_player && rf::local_player->cam - && rf::local_player->cam->mode == rf::CameraMode::CAMERA_FREELOOK; + && rf::local_player->cam->mode == rf::CAMERA_FREELOOK; } +// Per-frame raw mouse deltas captured for centralized camera angle computation. +// Populated by mouse_get_delta_hook during gameplay; consumed by mouse_get_camera. +static int g_camera_mouse_dx = 0, g_camera_mouse_dy = 0; + // Sub-pixel remainder accumulators for vehicle mouse sensitivity scaling. static float g_vehicle_mouse_dx_rem = 0.0f, g_vehicle_mouse_dy_rem = 0.0f; @@ -72,6 +85,49 @@ bool set_direct_input_enabled(bool enabled) return true; } +void set_input_mode(int mode) +{ + mode = std::clamp(mode, 0, 2); + + const int old_mode = g_alpine_game_config.input_mode; + g_alpine_game_config.input_mode = mode; + + if (!rf::is_dedicated_server) { + // Handle SDL relative mouse mode + if (g_sdl_window) { + SDL_SetWindowRelativeMouseMode(g_sdl_window, mode == 2 && rf::keep_mouse_centered); + } + + // Handle DirectInput transitions + if (mode == 1 && rf::keep_mouse_centered) { + set_direct_input_enabled(true); + } else if (old_mode == 1 && mode != 1) { + set_direct_input_enabled(false); + } + } + + // Clear SDL state when leaving SDL mode + if (old_mode == 2 && mode != 2) { + g_sdl_mouse_dx = 0; + g_sdl_mouse_dy = 0; + g_sdl_mouse_dx_rem = 0.0f; + g_sdl_mouse_dy_rem = 0.0f; + g_sdl_mouse_wheel_rem = 0.0f; + } + + // Release held extra scan codes so they don't stay stuck after mode switch + if (old_mode != mode) { + for (int i = 0; i < CTRL_EXTRA_MOUSE_SCAN_COUNT; ++i) + rf::key_process_event(CTRL_EXTRA_MOUSE_SCAN_BASE + i, 0, 0); + for (int i = 0; i < CTRL_EXTRA_KEY_SCAN_COUNT; ++i) + rf::key_process_event(CTRL_EXTRA_KEY_SCAN_BASE + i, 0, 0); + g_pending_mouse_extra_btn_rebind = -1; + } + + // Keep the Alpine Settings UI button label in sync + ui_refresh_input_mode_label(); +} + FunHook mouse_eval_deltas_hook{ 0x0051DC70, []() { @@ -79,13 +135,42 @@ FunHook mouse_eval_deltas_hook{ return; } - // disable mouse when window is not active - if (rf::os_foreground() || g_alpine_game_config.background_mouse) { - mouse_eval_deltas_hook.call_target(); + if (!rf::os_foreground() && !g_alpine_game_config.background_mouse) { + // Discard any SDL motion that accumulated while unfocused + g_sdl_mouse_dx_rem = 0.0f; + g_sdl_mouse_dy_rem = 0.0f; + return; + } + + if (g_alpine_game_config.input_mode == 2) { + // SDL mode: accumulate SDL motion events into integer deltas for this frame + g_sdl_mouse_dx = static_cast(g_sdl_mouse_dx_rem); + g_sdl_mouse_dy = static_cast(g_sdl_mouse_dy_rem); + g_sdl_mouse_dx_rem -= g_sdl_mouse_dx; + g_sdl_mouse_dy_rem -= g_sdl_mouse_dy; + // Do NOT touch mouse_old_z here — mouse_eval_deltas_di_hook uses the + // difference between mouse_wheel_pos (updated by SDL poll) and mouse_old_z + // (from the previous frame) to compute mouse_dz. Setting it here before + // call_target() would zero out the delta every frame. + } + + mouse_eval_deltas_hook.call_target(); + + // Cursor centering fallback for SDL mode when relative mouse mode is unavailable (e.g. no SDL window) + if (rf::keep_mouse_centered && g_alpine_game_config.input_mode == 2 && (!g_sdl_window || !SDL_GetWindowRelativeMouseMode(g_sdl_window))) { + RECT rect{}; + GetClientRect(rf::main_wnd, &rect); + POINT pt{rect.right / 2, rect.bottom / 2}; + ClientToScreen(rf::main_wnd, &pt); + SetCursorPos(pt.x, pt.y); + SDL_PumpEvents(); + SDL_FlushEvents(SDL_EVENT_MOUSE_MOTION, SDL_EVENT_MOUSE_MOTION); } }, }; +// Handles scroll-wheel delta fix and Win32 cursor centering for Legacy/DInput modes (0 and 1). +// In SDL mode (2) this hook fires but we skip its extra work — SDL manages it instead. FunHook mouse_eval_deltas_di_hook{ 0x0051DEB0, []() { @@ -97,10 +182,19 @@ FunHook mouse_eval_deltas_di_hook{ mouse_eval_deltas_di_hook.call_target(); - // Fix invalid mouse scroll delta, when DirectInput is turned off. - rf::mouse_old_z = rf::mouse_wheel_pos; + if (g_alpine_game_config.input_mode == 2 && rf::keep_mouse_centered) { + rf::mouse_dz = rf::mouse_wheel_pos - rf::mouse_old_z; + rf::mouse_old_z = rf::mouse_wheel_pos; + return; + } + + // In SDL menu mode, allow the original code path to continue so UI cursor behavior works. + + // Fix invalid mouse scroll delta when DirectInput is not active (mode 0 or SDL menu mode) + if (g_alpine_game_config.input_mode != 1) + rf::mouse_old_z = rf::mouse_wheel_pos; - // center cursor if in game + // Keep Win32 cursor at window centre so delta-from-centre aiming stays accurate if (rf::keep_mouse_centered) { POINT pt{rf::gr::screen_width() / 2, rf::gr::screen_height() / 2}; ClientToScreen(rf::main_wnd, &pt); @@ -114,12 +208,28 @@ FunHook mouse_keep_centered_enable_hook{ []() { if (client_bot_headless_enabled()) { rf::keep_mouse_centered = false; + if (g_sdl_window) + SDL_SetWindowRelativeMouseMode(g_sdl_window, false); set_direct_input_enabled(false); return; } - if (!rf::keep_mouse_centered && !rf::is_dedicated_server) - set_direct_input_enabled(g_alpine_game_config.direct_input); + // keep_mouse_centered is still false here; call_target sets it + if (!rf::keep_mouse_centered && !rf::is_dedicated_server) { + switch (g_alpine_game_config.input_mode) { + case 1: // DirectInput mouse + set_direct_input_enabled(true); + break; + case 2: // SDL mouse + if (g_sdl_window) { + SDL_SetWindowRelativeMouseMode(g_sdl_window, true); + } else if (!g_relative_mouse_mode_window_missing_logged) { + xlog::warn("mouse_keep_centered_enable_hook: SDL window is null, cannot enable relative mouse mode"); + g_relative_mouse_mode_window_missing_logged = true; + } + break; + } + } mouse_keep_centered_enable_hook.call_target(); }, }; @@ -129,12 +239,27 @@ FunHook mouse_keep_centered_disable_hook{ []() { if (client_bot_headless_enabled()) { rf::keep_mouse_centered = false; + if (g_sdl_window) + SDL_SetWindowRelativeMouseMode(g_sdl_window, false); set_direct_input_enabled(false); return; } - if (rf::keep_mouse_centered) { - set_direct_input_enabled(false); + // keep_mouse_centered is still true here; call_target clears it + if (rf::keep_mouse_centered && !rf::is_dedicated_server) { + switch (g_alpine_game_config.input_mode) { + case 1: // DirectInput mouse + set_direct_input_enabled(false); + break; + case 2: // SDL mouse + if (g_sdl_window) { + SDL_SetWindowRelativeMouseMode(g_sdl_window, false); + } else if (!g_relative_mouse_mode_window_missing_logged) { + xlog::warn("mouse_keep_centered_disable_hook: SDL window is null, cannot disable relative mouse mode"); + g_relative_mouse_mode_window_missing_logged = true; + } + break; + } reset_mouse_delta_accumulators(); } mouse_keep_centered_disable_hook.call_target(); @@ -146,6 +271,17 @@ FunHook mouse_get_delta_hook{ [](int& dx, int& dy, int& dz) { mouse_get_delta_hook.call_target(dx, dy, dz); // fills dz (scroll wheel) + if (g_alpine_game_config.input_mode == 2 && g_sdl_window) { + // SDL mode: override dx/dy with SDL-sourced deltas when SDL window is available + dx = g_sdl_mouse_dx; + dy = g_sdl_mouse_dy; + g_sdl_mouse_dx = 0; + g_sdl_mouse_dy = 0; + + // Also pass through wheel delta for weapon switching/menu scroll actions. + dz = rf::mouse_dz; + } + // Nothing to do in Classic mode or outside gameplay. if (!rf::keep_mouse_centered || g_alpine_game_config.mouse_scale == 0) { reset_mouse_delta_accumulators(); @@ -165,10 +301,9 @@ FunHook mouse_get_delta_hook{ // In Raw/Modern mode: capture raw deltas for centralized angle // computation and zero them so RF does not apply its own scaling. - // Skip when in a vehicle (RF needs the deltas to steer), but scale + // Skip zeroing when in a vehicle (RF needs the deltas to steer), but scale // them down to stay consistent with the camera formula feel. - bool in_vehicle = rf::local_player_entity && - rf::entity_in_vehicle(rf::local_player_entity); + bool in_vehicle = rf::local_player_entity && rf::entity_in_vehicle(rf::local_player_entity); if (!in_vehicle) { g_camera_mouse_dx += dx; g_camera_mouse_dy += dy; @@ -189,30 +324,28 @@ FunHook mouse_get_delta_hook{ ConsoleCommand2 input_mode_cmd{ "inputmode", - []() { + [](std::optional mode_opt) { + static constexpr const char* mode_names[] = {"Legacy", "DirectInput", "SDL"}; + if (client_bot_headless_enabled()) { - g_alpine_game_config.direct_input = false; - set_direct_input_enabled(false); - rf::console::print("DirectInput is disabled in headless bot mode"); + set_input_mode(0); + rf::console::print("Input mode is disabled in headless bot mode"); return; } - g_alpine_game_config.direct_input = !g_alpine_game_config.direct_input; - - if (g_alpine_game_config.direct_input) { - if (!set_direct_input_enabled(g_alpine_game_config.direct_input)) { - rf::console::print("Failed to initialize DirectInput"); - } - else { - set_direct_input_enabled(rf::keep_mouse_centered); - rf::console::print("DirectInput is enabled"); - } - } - else { - rf::console::print("DirectInput is disabled"); + if (!mode_opt) { + // No argument: print current mode + int cur = g_alpine_game_config.input_mode; + rf::console::print("Input mode: {} ({})", cur, mode_names[cur]); + return; } + + int new_mode = std::clamp(mode_opt.value(), 0, 2); + set_input_mode(new_mode); + rf::console::print("Input mode: {} ({})", new_mode, mode_names[new_mode]); }, - "Toggles DirectInput mouse mode", + "Gets or sets input mode: 0=Legacy Win32, 1=DirectInput mouse+Legacy keyboard, 2=SDL mouse+keyboard", + "inputmode [0|1|2]", }; ConsoleCommand2 ms_cmd{ @@ -324,6 +457,122 @@ CodeInjection static_zoom_sensitivity_patch2 { }, }; +// Handle an SDL extra mouse button event (Mouse 4-8). +// Maps SDL button indices to custom scan codes and injects them into RF's key layer. +// Only active in SDL input mode (mode 2). +static void handle_extra_mouse_button(const SDL_Event& ev) +{ + if (g_alpine_game_config.input_mode != 2) + return; + + if (ev.button.button < SDL_BUTTON_X1 || + ev.button.button >= SDL_BUTTON_X1 + CTRL_EXTRA_MOUSE_SCAN_COUNT) + return; + + int rf_btn = static_cast(ev.button.button) - 1; // SDL 4→rf 3, SDL 5→rf 4 ... + + if (ev.button.down && g_pending_mouse_extra_btn_rebind < 0 + && rf::ui::options_controls_waiting_for_key) { + // Rebind UI active: inject the sentinel key so RF processes the rebind, + // then ui.cpp's falling-edge handler replaces it with our custom scan code. + g_pending_mouse_extra_btn_rebind = rf_btn; + rf::key_process_event(CTRL_REBIND_SENTINEL, 1, 0); + } else { + // Inject our custom scan code directly into RF's key state. + int scan_code = CTRL_EXTRA_MOUSE_SCAN_BASE + (rf_btn - 3); + rf::key_process_event(scan_code, ev.button.down ? 1 : 0, 0); + } +} + +void mouse_sdl_poll() +{ + if (!g_sdl_window) return; + + SDL_Event events[16]; + int n; + // Consume motion, button, and wheel events; leave device-change events in the queue. + while ((n = SDL_PeepEvents(events, static_cast(std::size(events)), + SDL_GETEVENT, SDL_EVENT_MOUSE_MOTION, + SDL_EVENT_MOUSE_WHEEL)) > 0) { + for (int i = 0; i < n; ++i) { + const SDL_Event& ev = events[i]; + switch (ev.type) { + case SDL_EVENT_MOUSE_MOTION: + if (ev.motion.which == SDL_TOUCH_MOUSEID || ev.motion.which == SDL_PEN_MOUSEID) { + // Touch/pen: move the Win32 cursor to the contact point in menus. + // Works in all input modes; ignored during gameplay + if (!rf::keep_mouse_centered) { + POINT pt{static_cast(ev.motion.x), static_cast(ev.motion.y)}; + ClientToScreen(rf::main_wnd, &pt); + SetCursorPos(pt.x, pt.y); + } + } else if (g_alpine_game_config.input_mode == 2) { + // Real mouse: accumulate relative deltas for the camera (SDL mode only). + g_sdl_mouse_dx_rem += ev.motion.xrel; + g_sdl_mouse_dy_rem += ev.motion.yrel; + } + break; + case SDL_EVENT_MOUSE_BUTTON_DOWN: + case SDL_EVENT_MOUSE_BUTTON_UP: + if (ev.button.which == SDL_TOUCH_MOUSEID || ev.button.which == SDL_PEN_MOUSEID) { + // Touch/pen taps: synthesise a left-click in menus; ignored during gameplay. + // Works in all input modes; not part of the binding system. + if (!rf::keep_mouse_centered && ev.button.button == SDL_BUTTON_LEFT) { + // Sync cursor to the exact tap position first. + POINT pt{static_cast(ev.button.x), static_cast(ev.button.y)}; + ClientToScreen(rf::main_wnd, &pt); + SetCursorPos(pt.x, pt.y); + UINT wmsg = (ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN) ? WM_LBUTTONDOWN : WM_LBUTTONUP; + WPARAM wp = (ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN) ? MK_LBUTTON : 0; + PostMessageA(rf::main_wnd, wmsg, wp, + MAKELPARAM(static_cast(ev.button.x), static_cast(ev.button.y))); + } + } else { + handle_extra_mouse_button(ev); + } + break; + case SDL_EVENT_MOUSE_WHEEL: + // Feed scroll into rf::mouse_wheel_pos (120 units per notch, matching Win32/DInput). + // SDL y > 0 = scroll up = positive delta, same sign as RF expects. + if (g_alpine_game_config.input_mode == 2 + && ev.wheel.which != SDL_TOUCH_MOUSEID + && ev.wheel.which != SDL_PEN_MOUSEID) { + float dy = (ev.wheel.direction == SDL_MOUSEWHEEL_FLIPPED) + ? -ev.wheel.y : ev.wheel.y; + g_sdl_mouse_wheel_rem += dy; + int notches = static_cast(g_sdl_mouse_wheel_rem); + if (notches != 0) { + rf::mouse_wheel_pos += notches * 120; + g_sdl_mouse_wheel_rem -= static_cast(notches); + } + } + break; + default: + break; + } + } + } +} + +int mouse_take_pending_rebind() +{ + int btn = g_pending_mouse_extra_btn_rebind; + g_pending_mouse_extra_btn_rebind = -1; + return btn; +} + +void mouse_init_sdl_window() +{ + SDL_PropertiesID props = SDL_CreateProperties(); + SDL_SetPointerProperty(props, SDL_PROP_WINDOW_CREATE_WIN32_HWND_POINTER, rf::main_wnd); + g_sdl_window = SDL_CreateWindowWithProperties(props); + SDL_DestroyProperties(props); + if (!g_sdl_window) { + xlog::error("SDL_CreateWindowWithProperties failed: {}", SDL_GetError()); + return; + } +} + // Converts the per-frame raw mouse pixel deltas captured into camera angle deltas (radians). // Mode 1 (Raw): pure camera angles — 360 pixels * sens = 360 degree camera turn (angle = raw_pixels * sens * deg2rad) // Mode 2 (Modern): angle = raw_pixels * sens * 0.022 deg/pixel * deg2rad (id Tech/Source formula) @@ -369,6 +618,7 @@ void mouse_get_camera(float& pitch_delta, float& yaw_delta) g_camera_mouse_dy = 0; } + void mouse_apply_patch() { // Handle zoom sens customization @@ -382,8 +632,10 @@ void mouse_apply_patch() // Disable mouse when window is not active mouse_eval_deltas_hook.install(); - // Add DirectInput mouse support + // Scroll-wheel fix and Win32 cursor centering for Legacy/DInput modes (0 and 1) mouse_eval_deltas_di_hook.install(); + + // Mouse mode hooks (DInput or SDL depending on input_mode) mouse_keep_centered_enable_hook.install(); mouse_keep_centered_disable_hook.install(); mouse_get_delta_hook.install(); @@ -391,14 +643,11 @@ void mouse_apply_patch() // Do not limit the cursor to the game window if in menu (Win32 mouse) AsmWriter(0x0051DD7C).jmp(0x0051DD8E); - // Use exclusive DirectInput mode so cursor cannot exit game window - //write_mem(0x0051E14B + 1, 5); // DISCL_EXCLUSIVE|DISCL_FOREGROUND - // Commands input_mode_cmd.register_cmd(); ms_cmd.register_cmd(); + ms_scale_cmd.register_cmd(); static_scope_sens_cmd.register_cmd(); scope_sens_cmd.register_cmd(); scanner_sens_cmd.register_cmd(); - ms_scale_cmd.register_cmd(); } diff --git a/game_patch/input/mouse.h b/game_patch/input/mouse.h index e08d0d629..0f65db792 100644 --- a/game_patch/input/mouse.h +++ b/game_patch/input/mouse.h @@ -1,4 +1,12 @@ #pragma once +// Custom scan codes for extra mouse buttons (Mouse 4 and above), stored in scan_codes[0]. +// Range placed after CTRL_GAMEPAD_RIGHT_TRIGGER (0x74) to avoid conflicts. +static constexpr int CTRL_EXTRA_MOUSE_SCAN_BASE = 0x75; // scan code for rf_btn 3 (Mouse 4) +static constexpr int CTRL_EXTRA_MOUSE_SCAN_COUNT = 5; // covers Mouse 4-8 (rf indices 3-7) + void mouse_apply_patch(); +void mouse_init_sdl_window(); +int mouse_take_pending_rebind(); +void mouse_sdl_poll(); void mouse_get_camera(float& pitch_delta, float& yaw_delta); diff --git a/game_patch/main/main.cpp b/game_patch/main/main.cpp index 219f5e16a..0996d1287 100644 --- a/game_patch/main/main.cpp +++ b/game_patch/main/main.cpp @@ -76,6 +76,9 @@ CallHook rf_init_hook{ xlog::info("Initializing game..."); initialize_alpine_core_config(); rf_init_hook.call_target(); + if (!rf::is_dedicated_server && rf::main_wnd) { + mouse_init_sdl_window(); + } vpackfile_disable_overriding(); xlog::info("Game initialized ({} ms).", GetTickCount64() - start_ticks); }, diff --git a/game_patch/misc/alpine_settings.cpp b/game_patch/misc/alpine_settings.cpp index 05a2bfc97..6145fe4e9 100644 --- a/game_patch/misc/alpine_settings.cpp +++ b/game_patch/misc/alpine_settings.cpp @@ -6,6 +6,7 @@ #include "alpine_settings.h" #include "alpine_options.h" #include +#include "../input/input.h" #include "../os/console.h" #include "../os/os.h" #include "../rf/ui.h" @@ -21,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -989,8 +991,18 @@ bool alpine_player_settings_load(rf::Player* player) player->settings.controls.axes[1].invert = std::stoi(settings["MouseYInvert"]); processed_keys.insert("MouseYInvert"); } - if (settings.count("DirectInput")) { - g_alpine_game_config.direct_input = std::stoi(settings["DirectInput"]); + if (settings.count("InputMode")) { + int input_mode = std::stoi(settings["InputMode"]); + set_input_mode(std::clamp(input_mode, 0, 2)); + processed_keys.insert("InputMode"); + } else if (settings.count("SDL")) { + int sdl_mode = std::stoi(settings["SDL"]); + set_input_mode((sdl_mode != 0) ? 2 : 1); + processed_keys.insert("SDL"); + } else if (settings.count("DirectInput")) { + // Legacy: DirectInput=0 was Legacy/Win32 (mode 0), DirectInput=1 was DInput (mode 1) + int direct_input = std::stoi(settings["DirectInput"]); + set_input_mode((direct_input != 0) ? 1 : 0); processed_keys.insert("DirectInput"); } if (settings.count("MouseLinearPitch")) { @@ -1101,7 +1113,7 @@ void alpine_control_config_serialize(std::ofstream& file, const rf::ControlConfi file << "\n[InputSettings]\n"; file << "MouseSensitivity=" << cc.mouse_sensitivity << "\n"; file << "MouseYInvert=" << cc.axes[1].invert << "\n"; - file << "DirectInput=" << g_alpine_game_config.direct_input << "\n"; + file << "InputMode=" << g_alpine_game_config.input_mode << "\n"; file << "MouseLinearPitch=" << g_alpine_game_config.mouse_linear_pitch << "\n"; file << "MouseScale=" << g_alpine_game_config.mouse_scale << "\n"; file << "SwapARBinds=" << g_alpine_game_config.swap_ar_controls << "\n"; @@ -1415,7 +1427,7 @@ static void set_headless_bot_defaults() g_alpine_game_config.swap_ar_controls = false; g_alpine_game_config.swap_gn_controls = false; g_alpine_game_config.swap_sg_controls = false; - g_alpine_game_config.direct_input = false; + g_alpine_game_config.input_mode = 0; // headless mode uses legacy mouse path g_alpine_game_config.save_console_history = false; g_alpine_game_config.set_max_fps(30); g_alpine_game_config.dbg_bot = false; diff --git a/game_patch/misc/alpine_settings.h b/game_patch/misc/alpine_settings.h index 146ae3f27..680f4b32f 100644 --- a/game_patch/misc/alpine_settings.h +++ b/game_patch/misc/alpine_settings.h @@ -137,7 +137,7 @@ struct AlpineGameSettings bool display_target_player_names = true; bool verbose_time_left_display = true; bool nearest_texture_filtering = false; - bool direct_input = true; + int input_mode = 2; // 0=Win32 mouse+keyboard (referred as Classic), 1=DInput mouse+Win32 keyboard, 2=SDL mouse+keyboard bool scoreboard_anim = true; bool legacy_bob = false; bool scoreboard_split_simple = true; diff --git a/game_patch/misc/ui.cpp b/game_patch/misc/ui.cpp index 84969b165..8bdad5bff 100644 --- a/game_patch/misc/ui.cpp +++ b/game_patch/misc/ui.cpp @@ -19,6 +19,7 @@ #include "../rf/misc.h" #include "../rf/os/os.h" #include "../object/object.h" +#include "../input/input.h" #define DEBUG_UI_LAYOUT 0 #define SHARP_UI_TEXT 1 @@ -89,8 +90,12 @@ static char ao_simdist_butlabel_text[9]; // alpine options checkboxes and labels static rf::ui::Checkbox ao_mpcharlod_cbox; static rf::ui::Label ao_mpcharlod_label; -static rf::ui::Checkbox ao_dinput_cbox; -static rf::ui::Label ao_dinput_label; +static rf::ui::Checkbox ao_input_mode_cbox; +static rf::ui::Label ao_input_mode_label; +static rf::ui::Label ao_input_mode_butlabel; +static char ao_input_mode_butlabel_text[16]; +static bool g_alpine_options_just_switched_input_mode = false; +static constexpr const char* input_mode_names[] = {"Classic", "DInput", "SDL"}; static rf::ui::Checkbox ao_linearpitch_cbox; static rf::ui::Label ao_linearpitch_label; static rf::ui::Checkbox ao_mousecamerascale_cbox; @@ -546,11 +551,19 @@ void ao_bighud_cbox_on_click(int x, int y) { ao_play_button_snd(g_alpine_game_config.big_hud); } -void ao_dinput_cbox_on_click(int x, int y) +void ui_refresh_input_mode_label() { - g_alpine_game_config.direct_input = !g_alpine_game_config.direct_input; - ao_dinput_cbox.checked = g_alpine_game_config.direct_input; - ao_play_button_snd(g_alpine_game_config.direct_input); + int mode_index = std::clamp(g_alpine_game_config.input_mode, 0, 2); + snprintf(ao_input_mode_butlabel_text, sizeof(ao_input_mode_butlabel_text), "%s", + input_mode_names[mode_index]); + ao_input_mode_butlabel.text = ao_input_mode_butlabel_text; +} + +void ao_input_mode_cbox_on_click([[maybe_unused]] int x, [[maybe_unused]] int y) { + set_input_mode((g_alpine_game_config.input_mode + 1) % 3); + ui_refresh_input_mode_label(); + g_alpine_options_just_switched_input_mode = true; + ao_play_button_snd(true); } void ao_linearpitch_cbox_on_click(int x, int y) { @@ -998,10 +1011,18 @@ void alpine_options_panel_handle_key(rf::Key* key){ // todo: more key support (tab, etc.) // close panel on escape if (*key == rf::Key::KEY_ESC) { + if (g_alpine_options_just_switched_input_mode) { + // Ignore the ESC stutter that can occur when switching to SDL mode. + g_alpine_options_just_switched_input_mode = false; + return; + } + rf::ui::options_close_current_panel(); rf::snd_play(43, 0, 0.0f, 1.0f); return; } + + g_alpine_options_just_switched_input_mode = false; } void alpine_options_panel_handle_mouse(int x, int y) { @@ -1202,8 +1223,9 @@ void alpine_options_panel_init() { &ao_always_show_spectators_cbox, &ao_always_show_spectators_label, &alpine_options_panel1, ao_always_show_spectators_cbox_on_click, g_alpine_game_config.always_show_spectators, 280, 264, "Show spectators"); // panel 2 - alpine_options_panel_checkbox_init( - &ao_dinput_cbox, &ao_dinput_label, &alpine_options_panel2, ao_dinput_cbox_on_click, g_alpine_game_config.direct_input, 112, 54, "DirectInput"); + alpine_options_panel_inputbox_init( + &ao_input_mode_cbox, &ao_input_mode_label, &ao_input_mode_butlabel, &alpine_options_panel2, ao_input_mode_cbox_on_click, 112, 54, "Input mode"); + ui_refresh_input_mode_label(); alpine_options_panel_checkbox_init( &ao_linearpitch_cbox, &ao_linearpitch_label, &alpine_options_panel2, ao_linearpitch_cbox_on_click, g_alpine_game_config.mouse_linear_pitch, 112, 84, "Linear pitch"); alpine_options_panel_checkbox_init( @@ -1521,8 +1543,45 @@ CodeInjection options_render_alpine_panel_patch{ int index = rf::ui::options_current_panel; //xlog::warn("render index {}", index); - if (index == 3 && !rf::ui::options_controls_waiting_for_key) { - render_ctrl_camscale_btns(); + // handle key rebinding in input options panel + if (index == 3) { + static bool s_was_waiting = false; + bool now_waiting = rf::ui::options_controls_waiting_for_key; + if (s_was_waiting && !now_waiting) { + int16_t new_sc = -1; + int xbtn = mouse_take_pending_rebind(); + if (xbtn >= 0) { + new_sc = static_cast(CTRL_EXTRA_MOUSE_SCAN_BASE + (xbtn - 3)); + } else { + int extra_key = key_take_pending_extra_rebind(); + if (extra_key >= 0) + new_sc = static_cast(extra_key); + } + if (new_sc >= 0 && rf::local_player) { + auto& cc = rf::local_player->settings.controls; + int n = std::min(cc.num_bindings, 128); + bool found = false; + for (int i = 0; i < n && !found; ++i) { + for (int slot = 0; slot < 2 && !found; ++slot) { + if (cc.bindings[i].scan_codes[slot] == static_cast(CTRL_REBIND_SENTINEL)) { + for (int j = 0; j < n; ++j) + for (int s = 0; s < 2; ++s) + if ((j != i || s != slot) && cc.bindings[j].scan_codes[s] == new_sc) + cc.bindings[j].scan_codes[s] = -1; + cc.bindings[i].scan_codes[slot] = new_sc; + found = true; + } + } + } + rf::key_process_event(CTRL_REBIND_SENTINEL, 0, 0); + } + } + s_was_waiting = now_waiting; + + // Render the mouse scale control button in the controls tab when user is not rebinding + if (!rf::ui::options_controls_waiting_for_key) { + render_ctrl_camscale_btns(); + } } // render alpine options panel diff --git a/game_patch/os/frametime.cpp b/game_patch/os/frametime.cpp index 9b4ff544c..4c29a3879 100644 --- a/game_patch/os/frametime.cpp +++ b/game_patch/os/frametime.cpp @@ -11,6 +11,7 @@ #include "../rf/hud.h" #include "../rf/entity.h" #include "../rf/os/frametime.h" +#include "os.h" #include "../multi/multi.h" #include "../main/main.h" #include "../misc/alpine_settings.h" diff --git a/game_patch/os/os.cpp b/game_patch/os/os.cpp index be82a9648..08230cc5d 100644 --- a/game_patch/os/os.cpp +++ b/game_patch/os/os.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -7,6 +8,8 @@ #include "../rf/os/os.h" #include "../rf/multi.h" #include "../rf/input.h" +#include "../input/input.h" +#include "../misc/alpine_settings.h" #include "../rf/crt.h" #include "../main/main.h" #include "../multi/multi.h" @@ -17,6 +20,8 @@ const char* get_win_msg_name(UINT msg); +static bool g_sdl_video_initialized = false; + FunHook os_poll_hook{ 0x00524B60, []() { @@ -34,6 +39,13 @@ FunHook os_poll_hook{ if (win32_console_is_enabled()) { win32_console_poll_input(); } + + // Always pump SDL events to avoid a backlog even when the + // window is unfocused; lower-level input handling is responsible + // for ignoring or clamping deltas when background input is disabled. + if (g_sdl_video_initialized) { + sdl_input_poll(); + } }, }; @@ -63,11 +75,17 @@ LRESULT WINAPI wnd_proc(HWND wnd_handle, UINT msg, WPARAM w_param, LPARAM l_para if (!rf::is_dedicated_server) { // Show cursor if window is not active if (w_param) { + if (g_sdl_video_initialized) { + SDL_HideCursor(); + } ShowCursor(FALSE); while (ShowCursor(FALSE) >= 0) ; } else { + if (g_sdl_video_initialized) { + SDL_ShowCursor(); + } ShowCursor(TRUE); while (ShowCursor(TRUE) < 0) ; @@ -92,6 +110,11 @@ LRESULT WINAPI wnd_proc(HWND wnd_handle, UINT msg, WPARAM w_param, LPARAM l_para } return DefWindowProcA(wnd_handle, msg, w_param, l_param); + case WM_SYSCOMMAND: + if ((w_param & 0xFFF0) == SC_KEYMENU) + return 0; + return DefWindowProcA(wnd_handle, msg, w_param, l_param); + case WM_QUIT: case WM_CLOSE: case WM_DESTROY: @@ -238,6 +261,7 @@ static FunHook os_close_hook{ []() { os_close_hook.call_target(); win32_console_close(); + SDL_Quit(); }, }; @@ -288,7 +312,7 @@ void wait_for(const float ms, const WaitableTimer& timer) { } Sleep(static_cast(ms)); } else { - // `SetWaitableTimer` requires 100-nanosecond intervals. + // SetWaitableTimer requires 100-nanosecond intervals. // Negative values indicate relative time. LARGE_INTEGER dur{ .QuadPart = -static_cast(static_cast(ms) * 10'000.) @@ -308,6 +332,21 @@ void wait_for(const float ms, const WaitableTimer& timer) { void os_apply_patch() { + // Lock to DPI_AWARENESS_CONTEXT_UNAWARE so the legacy D3D renderer's bitmap-scaling + // virtualisation stays active. + if (auto* set_dpi_ctx = reinterpret_cast( + GetProcAddress(GetModuleHandleA("user32.dll"), "SetProcessDpiAwarenessContext"))) { + set_dpi_ctx(reinterpret_cast(-1)); // DPI_AWARENESS_CONTEXT_UNAWARE + } + + if (!rf::is_dedicated_server) { + if (SDL_Init(SDL_INIT_VIDEO)) { + g_sdl_video_initialized = true; + } else { + xlog::error("SDL_Init(SDL_INIT_VIDEO) failed: {}", SDL_GetError()); + } + } + // Process messages in the same thread as DX processing (alternative: D3DCREATE_MULTITHREADED) AsmWriter(0x00524C48, 0x00524C83).nop(); // disable msg loop thread AsmWriter(0x00524C48).call(0x00524E40); // os_create_main_window @@ -336,4 +375,4 @@ void os_apply_patch() timer_apply_patch(); win32_console_pre_init(); -} +} \ No newline at end of file diff --git a/game_patch/rf/input.h b/game_patch/rf/input.h index eda4decd0..28a490d97 100644 --- a/game_patch/rf/input.h +++ b/game_patch/rf/input.h @@ -1,8 +1,7 @@ #pragma once // RF uses DirectInput 8 -#define DIRECTINPUT_VERSION 0x0800 - +#define DIRECTINPUT_VERSION 0x0800 #include #include diff --git a/resources/licensing-info.txt b/resources/licensing-info.txt index 29206ecfc..5dfad205e 100644 --- a/resources/licensing-info.txt +++ b/resources/licensing-info.txt @@ -461,3 +461,28 @@ freely, subject to the following restrictions: 3. This notice may not be removed or altered from any source distribution. René Nyffenegger rene.nyffenegger@adp-gmbh.ch + +############################################################################### +## sdl +############################################################################### + +Source code: https://github.com/libsdl-org/SDL + +Copyright (C) 1997-2026 Sam Lantinga + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution. + diff --git a/vendor/CMakeLists.txt b/vendor/CMakeLists.txt index 2814f31dc..646d49aaf 100644 --- a/vendor/CMakeLists.txt +++ b/vendor/CMakeLists.txt @@ -12,3 +12,4 @@ add_subdirectory(sha1) add_subdirectory(ed25519) add_subdirectory(base64) add_subdirectory(stb) +add_subdirectory(sdl) \ No newline at end of file diff --git a/vendor/sdl/CMakeLists.txt b/vendor/sdl/CMakeLists.txt new file mode 100644 index 000000000..cb40a2960 --- /dev/null +++ b/vendor/sdl/CMakeLists.txt @@ -0,0 +1,18 @@ +# SDL3 built from source via FetchContent: +# To upgrade, update SDL3_VERSION and SDL3_HASH to match the desired release tag. +# Hash can be found at: https://github.com/libsdl-org/SDL/releases +# See: https://wiki.libsdl.org/SDL3/README-cmake +set(SDL3_VERSION "3.4.4") +set(SDL3_HASH "ee712dbe6a89bb140bbfc2ce72358fb5ee5cc2240abeabd54855012db30b3864") + +include(FetchContent) +FetchContent_Declare( + SDL3 + URL "https://github.com/libsdl-org/SDL/releases/download/release-${SDL3_VERSION}/SDL3-${SDL3_VERSION}.tar.gz" + URL_HASH SHA256=${SDL3_HASH} + DOWNLOAD_EXTRACT_TIMESTAMP FALSE +) +set(SDL_STATIC ON CACHE BOOL "" FORCE) +set(SDL_SHARED OFF CACHE BOOL "" FORCE) +set(SDL_TEST OFF CACHE BOOL "" FORCE) +FetchContent_MakeAvailable(SDL3) \ No newline at end of file diff --git a/vendor/sdl/LICENSE.txt b/vendor/sdl/LICENSE.txt new file mode 100644 index 000000000..e9adee448 --- /dev/null +++ b/vendor/sdl/LICENSE.txt @@ -0,0 +1,18 @@ +Copyright (C) 1997-2026 Sam Lantinga + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution. +