src/input_intercept.cpp
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | /** | ||
| 2 | * @file input_intercept.cpp | ||
| 3 | * @brief Implementation of the internal active-input layer (input_intercept.hpp). | ||
| 4 | * | ||
| 5 | * Owns the XInputGetState inline hook and the window-procedure subclass that back gamepad passthrough suppression and | ||
| 6 | * mouse-wheel capture for InputPoller. | ||
| 7 | */ | ||
| 8 | |||
| 9 | #include "input_intercept.hpp" | ||
| 10 | #include "platform.hpp" | ||
| 11 | #include "DetourModKit/logger.hpp" | ||
| 12 | |||
| 13 | #include "safetyhook.hpp" | ||
| 14 | |||
| 15 | #include <atomic> | ||
| 16 | #include <cstdint> | ||
| 17 | #include <utility> | ||
| 18 | |||
| 19 | namespace DetourModKit::detail | ||
| 20 | { | ||
| 21 | namespace | ||
| 22 | { | ||
| 23 | /// XInput export resides in one of these DLLs depending on the game/runtime. | ||
| 24 | constexpr const wchar_t *XINPUT_DLL_NAMES[] = { | ||
| 25 | L"xinput1_4.dll", L"xinput1_3.dll", L"xinput9_1_0.dll", L"xinput1_2.dll", L"xinput1_1.dll", | ||
| 26 | }; | ||
| 27 | |||
| 28 | /// Undocumented ordinal that exports XInputGetStateEx (reports the Guide button). | ||
| 29 | constexpr WORD XINPUT_GET_STATE_EX_ORDINAL = 100; | ||
| 30 | |||
| 31 | /** | ||
| 32 | * @brief How long a published suppression mask stays valid without a refresh. | ||
| 33 | * @details Set above the maximum allowed poll interval (MAX_POLL_INTERVAL) so a healthy poll thread at any | ||
| 34 | * configured rate keeps the mask continuously alive, while still bounding a stalled poll thread so it | ||
| 35 | * cannot latch the game's input off indefinitely. Twice the largest poll interval leaves headroom for | ||
| 36 | * a slow cycle's own body to run before the deadline lapses. | ||
| 37 | */ | ||
| 38 | constexpr uint64_t SUPPRESS_TTL_MS = 2000; | ||
| 39 | |||
| 40 | // --- XInput interception state --- | ||
| 41 | |||
| 42 | safetyhook::InlineHook s_xinput_hook; | ||
| 43 | safetyhook::InlineHook s_xinput_ex_hook; | ||
| 44 | std::atomic<XInputGetStateFn> s_xinput_original{nullptr}; | ||
| 45 | std::atomic<XInputGetStateFn> s_xinput_ex_original{nullptr}; | ||
| 46 | std::atomic<bool> s_xinput_installed{false}; | ||
| 47 | // One-shot diagnostics latches: a failed InlineHook::enable() is otherwise swallowed silently, so surface each | ||
| 48 | // failure the first time it happens and stay quiet afterwards (install_xinput is retried every poll cycle, so | ||
| 49 | // an un-latched warning would spam the sink). uninstall() clears both so a later hot-reload re-arm can warn | ||
| 50 | // again. | ||
| 51 | std::atomic<bool> s_xinput_enable_warned{false}; | ||
| 52 | std::atomic<bool> s_xinput_ex_enable_warned{false}; | ||
| 53 | std::atomic<int> s_bound_user_index{0}; | ||
| 54 | std::atomic<uint16_t> s_suppress_mask{0}; | ||
| 55 | std::atomic<uint64_t> s_suppress_deadline_ms{0}; | ||
| 56 | |||
| 57 | // --- Consume rule list (detour-side chord evaluation) --- | ||
| 58 | // | ||
| 59 | // A binding rebuild publishes one rule per detour-evaluable consume chord; | ||
| 60 | // the XInput detour reads the list against the exact button snapshot the game is about to read. Each rule is | ||
| 61 | // packed into a single atomic word so a reader never sees a torn rule, and the array plus its count sit behind | ||
| 62 | // a seqlock (s_consume_rules_seq: even = stable, odd = mid-update) so the detour gets an all-or-nothing | ||
| 63 | // snapshot of the whole list without locking. Single writer: whichever thread mutates the bindings, serialized | ||
| 64 | // by | ||
| 65 | // InputPoller::m_bindings_rw_mutex held in write mode while recompute_modifier_caches_locked / clear_bindings | ||
| 66 | // publish. This is not the poll thread, which only takes a shared lock and never writes or reads this list. | ||
| 67 | // Many readers: the game's XInput caller threads via the detour. | ||
| 68 | std::array<std::atomic<uint64_t>, MAX_GAMEPAD_CONSUME_RULES> s_consume_rules{}; | ||
| 69 | std::atomic<uint32_t> s_consume_rule_count{0}; | ||
| 70 | std::atomic<uint32_t> s_consume_rules_seq{0}; | ||
| 71 | |||
| 72 | // Gate for detour-side rule masking, driven every poll cycle. The published rule list and its time-to-live | ||
| 73 | // survive focus changes, so without this gate apply_suppress would keep masking the foreground game's input | ||
| 74 | // while the mod is unfocused. The poll loop sets it true only while focused and connected, mirroring how the | ||
| 75 | // reactive mask is cleared and how s_wheel_consume is gated. | ||
| 76 | std::atomic<bool> s_rule_suppress_enabled{false}; | ||
| 77 | |||
| 78 | /** | ||
| 79 | * @brief Packs a rule into one word: modifier (bits 0-15), forbidden (16-31), trigger (32-47). | ||
| 80 | * @details Three 16-bit masks fit a uint64 with room to spare, so a rule is published and read as a single | ||
| 81 | * atomic store/load. | ||
| 82 | */ | ||
| 83 | 8 | constexpr uint64_t pack_consume_rule(const GamepadConsumeRule &rule) noexcept | |
| 84 | { | ||
| 85 | 8 | return static_cast<uint64_t>(rule.modifier_mask) | (static_cast<uint64_t>(rule.forbidden_mask) << 16) | | |
| 86 | 8 | (static_cast<uint64_t>(rule.trigger_mask) << 32); | |
| 87 | } | ||
| 88 | |||
| 89 | /// Inverse of pack_consume_rule. | ||
| 90 | 21 | constexpr GamepadConsumeRule unpack_consume_rule(uint64_t packed) noexcept | |
| 91 | { | ||
| 92 | return GamepadConsumeRule{static_cast<uint16_t>(packed & 0xFFFFu), | ||
| 93 | 21 | static_cast<uint16_t>((packed >> 16) & 0xFFFFu), | |
| 94 | 21 | static_cast<uint16_t>((packed >> 32) & 0xFFFFu)}; | |
| 95 | } | ||
| 96 | |||
| 97 | // --- Mouse-wheel capture state --- | ||
| 98 | |||
| 99 | std::array<std::atomic<int>, 4> s_wheel_count{}; | ||
| 100 | std::atomic<bool> s_wheel_consume{false}; | ||
| 101 | std::atomic<HWND> s_hwnd{nullptr}; | ||
| 102 | std::atomic<LONG_PTR> s_prev_wndproc{0}; | ||
| 103 | std::atomic<bool> s_wndproc_installed{false}; | ||
| 104 | |||
| 105 | /** | ||
| 106 | * @brief Clears the suppressed button bits from a game-bound XINPUT_STATE. | ||
| 107 | * @details Only the bound controller index is masked. dwPacketNumber and the success return are left untouched | ||
| 108 | * so the game still sees a connected, advancing controller (faking a disconnect would trigger | ||
| 109 | * pause/reconnect UI). The cleared bits are the union of two sources: | ||
| 110 | * the reactive mask the poll thread publishes (which carries the trailing-edge consume-until-release | ||
| 111 | * latch) and the consume rules evaluated here against the exact buttons the game is about to read | ||
| 112 | * (which close the leading-edge window the poll-published mask trails by up to one cycle). A | ||
| 113 | * time-to-live guard drops all masking if the poll thread stopped refreshing it. | ||
| 114 | */ | ||
| 115 | ✗ | void apply_suppress(XINPUT_STATE *state, DWORD user_index) noexcept | |
| 116 | { | ||
| 117 | ✗ | if (state == nullptr) | |
| 118 | { | ||
| 119 | ✗ | return; | |
| 120 | } | ||
| 121 | ✗ | if (static_cast<int>(user_index) != s_bound_user_index.load(std::memory_order_relaxed)) | |
| 122 | { | ||
| 123 | ✗ | return; | |
| 124 | } | ||
| 125 | // Acquire the reactive mask first. This load also orders the relaxed deadline read below: | ||
| 126 | // publish_gamepad_suppress writes the deadline before the release store on s_suppress_mask, so the acquire | ||
| 127 | // here establishes the happens-before even when the mask reads as 0. | ||
| 128 | ✗ | const uint16_t reactive = s_suppress_mask.load(std::memory_order_acquire); | |
| 129 | |||
| 130 | // raw is the true, unmasked state: this detour runs after the trampoline call. Evaluating the published | ||
| 131 | // chord rules against it masks a chord whose modifier and trigger were pressed inside one poll interval on | ||
| 132 | // the very frame the game reads it, rather than a cycle later. The focus gate suppresses this evaluation | ||
| 133 | // when the host window is unfocused or the controller is gone: the rule list and its deadline both survive | ||
| 134 | // those transitions, so the detour must not keep masking the foreground game's input (the reactive mask is | ||
| 135 | // already cleared by the poll loop on focus loss). | ||
| 136 | ✗ | const uint16_t raw = state->Gamepad.wButtons; | |
| 137 | const uint16_t rule_mask = | ||
| 138 | ✗ | s_rule_suppress_enabled.load(std::memory_order_relaxed) ? evaluate_published_consume_rules(raw) : 0; | |
| 139 | ✗ | const uint16_t mask = static_cast<uint16_t>(reactive | rule_mask); | |
| 140 | ✗ | if (mask == 0) | |
| 141 | { | ||
| 142 | ✗ | return; | |
| 143 | } | ||
| 144 | // The reactive mask and the rule list are both refreshed only while the poll thread is alive: rules exist | ||
| 145 | // only when consume gamepad bindings do, and that is exactly when publish_gamepad_suppress refreshes this | ||
| 146 | // deadline every cycle. A stalled poll thread therefore lets the deadline lapse and all masking stops, so | ||
| 147 | // the game regains its input rather than latching off. | ||
| 148 | ✗ | if (GetTickCount64() >= s_suppress_deadline_ms.load(std::memory_order_relaxed)) | |
| 149 | { | ||
| 150 | ✗ | return; | |
| 151 | } | ||
| 152 | ✗ | state->Gamepad.wButtons = static_cast<WORD>(raw & static_cast<WORD>(~mask)); | |
| 153 | } | ||
| 154 | |||
| 155 | 1 | DWORD WINAPI xinput_get_state_detour(DWORD user_index, XINPUT_STATE *state) noexcept | |
| 156 | { | ||
| 157 | 1 | const XInputGetStateFn original = s_xinput_original.load(std::memory_order_acquire); | |
| 158 |
1/2✓ Branch 3 → 4 taken 1 time.
✗ Branch 3 → 6 not taken.
|
1 | const DWORD result = (original != nullptr) ? original(user_index, state) : ERROR_DEVICE_NOT_CONNECTED; |
| 159 |
1/2✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 1 time.
|
1 | if (result == ERROR_SUCCESS) |
| 160 | { | ||
| 161 | ✗ | apply_suppress(state, user_index); | |
| 162 | } | ||
| 163 | 1 | return result; | |
| 164 | } | ||
| 165 | |||
| 166 | ✗ | DWORD WINAPI xinput_get_state_ex_detour(DWORD user_index, XINPUT_STATE *state) noexcept | |
| 167 | { | ||
| 168 | ✗ | const XInputGetStateFn original = s_xinput_ex_original.load(std::memory_order_acquire); | |
| 169 | ✗ | const DWORD result = (original != nullptr) ? original(user_index, state) : ERROR_DEVICE_NOT_CONNECTED; | |
| 170 | ✗ | if (result == ERROR_SUCCESS) | |
| 171 | { | ||
| 172 | ✗ | apply_suppress(state, user_index); | |
| 173 | } | ||
| 174 | ✗ | return result; | |
| 175 | } | ||
| 176 | |||
| 177 | 19 | LRESULT CALLBACK wndproc_detour(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam) noexcept | |
| 178 | { | ||
| 179 | 19 | const WNDPROC prev = reinterpret_cast<WNDPROC>(s_prev_wndproc.load(std::memory_order_acquire)); | |
| 180 | |||
| 181 |
4/4✓ Branch 9 → 10 taken 10 times.
✓ Branch 9 → 24 taken 4 times.
✓ Branch 9 → 38 taken 1 time.
✓ Branch 9 → 49 taken 4 times.
|
19 | switch (msg) |
| 182 | { | ||
| 183 | 10 | case WM_MOUSEWHEEL: | |
| 184 | { | ||
| 185 | // GET_WHEEL_DELTA_WPARAM is a signed short: positive scrolls the wheel forward (up/away from the user), | ||
| 186 | // negative backward (down). | ||
| 187 | 10 | const int delta = GET_WHEEL_DELTA_WPARAM(wparam); | |
| 188 |
2/2✓ Branch 10 → 11 taken 9 times.
✓ Branch 10 → 15 taken 1 time.
|
10 | if (delta > 0) |
| 189 | { | ||
| 190 | // Up | ||
| 191 | 9 | s_wheel_count[0].fetch_add(1, std::memory_order_relaxed); | |
| 192 | } | ||
| 193 |
1/2✓ Branch 15 → 16 taken 1 time.
✗ Branch 15 → 20 not taken.
|
1 | else if (delta < 0) |
| 194 | { | ||
| 195 | // Down | ||
| 196 | 1 | s_wheel_count[1].fetch_add(1, std::memory_order_relaxed); | |
| 197 | } | ||
| 198 |
2/2✓ Branch 21 → 22 taken 3 times.
✓ Branch 21 → 23 taken 7 times.
|
10 | if (s_wheel_consume.load(std::memory_order_relaxed)) |
| 199 | { | ||
| 200 | 3 | return 0; | |
| 201 | } | ||
| 202 | 7 | break; | |
| 203 | } | ||
| 204 | 4 | case WM_MOUSEHWHEEL: | |
| 205 | { | ||
| 206 | // Horizontal wheel sign is opposite the vertical intuition: | ||
| 207 | // positive tilts right, negative left. | ||
| 208 | 4 | const int delta = GET_WHEEL_DELTA_WPARAM(wparam); | |
| 209 |
2/2✓ Branch 24 → 25 taken 1 time.
✓ Branch 24 → 29 taken 3 times.
|
4 | if (delta > 0) |
| 210 | { | ||
| 211 | // Right | ||
| 212 | 1 | s_wheel_count[3].fetch_add(1, std::memory_order_relaxed); | |
| 213 | } | ||
| 214 |
1/2✓ Branch 29 → 30 taken 3 times.
✗ Branch 29 → 34 not taken.
|
3 | else if (delta < 0) |
| 215 | { | ||
| 216 | // Left | ||
| 217 | 3 | s_wheel_count[2].fetch_add(1, std::memory_order_relaxed); | |
| 218 | } | ||
| 219 |
1/2✗ Branch 35 → 36 not taken.
✓ Branch 35 → 37 taken 4 times.
|
4 | if (s_wheel_consume.load(std::memory_order_relaxed)) |
| 220 | { | ||
| 221 | ✗ | return 0; | |
| 222 | } | ||
| 223 | 4 | break; | |
| 224 | } | ||
| 225 | 1 | case WM_NCDESTROY: | |
| 226 | // The window is being destroyed and its window-long storage is about to be invalidated. Drop all | ||
| 227 | // tracked subclass state and mark the subclass uninstalled so a later poll cycle re-subclasses the next | ||
| 228 | // game window: an engine that recreates its window on a fullscreen/display-mode switch would otherwise | ||
| 229 | // leave the new window unhooked, because install_wndproc short-circuits while s_wndproc_installed stays | ||
| 230 | // true. The forward at the bottom of this function uses the local prev copy captured above, so clearing | ||
| 231 | // s_prev_wndproc here does not affect this invocation's own forward. Store the installed flag last so a | ||
| 232 | // poll thread observing it false (acquire) also sees the cleared handle and predecessor. | ||
| 233 | 1 | s_hwnd.store(nullptr, std::memory_order_release); | |
| 234 | s_prev_wndproc.store(0, std::memory_order_release); | ||
| 235 | 1 | s_wndproc_installed.store(false, std::memory_order_release); | |
| 236 | 1 | break; | |
| 237 | 4 | default: | |
| 238 | 4 | break; | |
| 239 | } | ||
| 240 | |||
| 241 |
1/2✓ Branch 50 → 51 taken 16 times.
✗ Branch 50 → 53 not taken.
|
16 | if (prev != nullptr) |
| 242 | { | ||
| 243 | 16 | return CallWindowProcW(prev, hwnd, msg, wparam, lparam); | |
| 244 | } | ||
| 245 | ✗ | return DefWindowProcW(hwnd, msg, wparam, lparam); | |
| 246 | } | ||
| 247 | |||
| 248 | 42 | BOOL CALLBACK find_window_proc(HWND hwnd, LPARAM lparam) noexcept | |
| 249 | { | ||
| 250 | 42 | auto *out = reinterpret_cast<HWND *>(lparam); | |
| 251 | 42 | DWORD window_pid = 0; | |
| 252 | 42 | GetWindowThreadProcessId(hwnd, &window_pid); | |
| 253 | // Accept the first visible, top-level (owner-less) window belonging to this process. The owner check | ||
| 254 | // filters tool/splash windows; visibility filters message-only and hidden helper windows. | ||
| 255 |
6/8✓ Branch 4 → 5 taken 7 times.
✓ Branch 4 → 9 taken 35 times.
✓ Branch 6 → 7 taken 7 times.
✗ Branch 6 → 9 not taken.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 7 times.
✓ Branch 11 → 12 taken 35 times.
✓ Branch 11 → 13 taken 7 times.
|
42 | if (window_pid != GetCurrentProcessId() || !IsWindowVisible(hwnd) || GetWindow(hwnd, GW_OWNER) != nullptr) |
| 256 | { | ||
| 257 | 35 | return TRUE; // keep enumerating | |
| 258 | } | ||
| 259 | 7 | *out = hwnd; | |
| 260 | 7 | return FALSE; // stop | |
| 261 | } | ||
| 262 | |||
| 263 | 7 | HWND find_game_window() noexcept | |
| 264 | { | ||
| 265 | 7 | HWND result = nullptr; | |
| 266 | 7 | EnumWindows(&find_window_proc, reinterpret_cast<LPARAM>(&result)); | |
| 267 |
1/2✓ Branch 3 → 4 taken 7 times.
✗ Branch 3 → 5 not taken.
|
7 | if (result != nullptr) |
| 268 | { | ||
| 269 | 7 | return result; | |
| 270 | } | ||
| 271 | // Fallback: the foreground window if it belongs to this process. | ||
| 272 | ✗ | const HWND foreground = GetForegroundWindow(); | |
| 273 | ✗ | if (foreground != nullptr) | |
| 274 | { | ||
| 275 | ✗ | DWORD pid = 0; | |
| 276 | ✗ | GetWindowThreadProcessId(foreground, &pid); | |
| 277 | ✗ | if (pid == GetCurrentProcessId()) | |
| 278 | { | ||
| 279 | ✗ | return foreground; | |
| 280 | } | ||
| 281 | } | ||
| 282 | ✗ | return nullptr; | |
| 283 | } | ||
| 284 | |||
| 285 | 90 | void uninstall_wndproc() noexcept | |
| 286 | { | ||
| 287 |
2/2✓ Branch 3 → 4 taken 84 times.
✓ Branch 3 → 5 taken 6 times.
|
90 | if (!s_wndproc_installed.load(std::memory_order_acquire)) |
| 288 | { | ||
| 289 | 84 | return; | |
| 290 | } | ||
| 291 | 6 | const HWND hwnd = s_hwnd.load(std::memory_order_acquire); | |
| 292 |
3/6✓ Branch 6 → 7 taken 6 times.
✗ Branch 6 → 9 not taken.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 6 times.
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 23 taken 6 times.
|
6 | if (hwnd == nullptr || !IsWindow(hwnd)) |
| 293 | { | ||
| 294 | // The window was already destroyed (WM_NCDESTROY cleared the handle, or it is otherwise gone); the | ||
| 295 | // subclass went with it. | ||
| 296 | ✗ | s_hwnd.store(nullptr, std::memory_order_release); | |
| 297 | s_prev_wndproc.store(0, std::memory_order_release); | ||
| 298 | ✗ | s_wndproc_installed.store(false, std::memory_order_release); | |
| 299 | ✗ | return; | |
| 300 | } | ||
| 301 | |||
| 302 | 6 | const WNDPROC current = reinterpret_cast<WNDPROC>(GetWindowLongPtrW(hwnd, GWLP_WNDPROC)); | |
| 303 |
1/2✓ Branch 24 → 25 taken 6 times.
✗ Branch 24 → 44 not taken.
|
6 | if (current == &wndproc_detour) |
| 304 | { | ||
| 305 | // Still the top of the chain: restoring the saved procedure is safe. | ||
| 306 | 6 | SetWindowLongPtrW(hwnd, GWLP_WNDPROC, s_prev_wndproc.load(std::memory_order_acquire)); | |
| 307 | 6 | s_hwnd.store(nullptr, std::memory_order_release); | |
| 308 | s_prev_wndproc.store(0, std::memory_order_release); | ||
| 309 | 6 | s_wndproc_installed.store(false, std::memory_order_release); | |
| 310 | 6 | return; | |
| 311 | } | ||
| 312 | |||
| 313 | // Another subclass layered on top of ours. Restoring here would clobber that mod's procedure, so leave our | ||
| 314 | // detour installed: it only forwards to s_prev_wndproc (kept intact) and is inert once wheel bindings are | ||
| 315 | // gone. Pin the module so the detour's code stays mapped even if this | ||
| 316 | // DLL is later unloaded, and keep s_wndproc_installed true so a later install does not stack a duplicate | ||
| 317 | // detour onto the chain. | ||
| 318 | ✗ | pin_current_module(); | |
| 319 | } | ||
| 320 | } // anonymous namespace | ||
| 321 | |||
| 322 | 50 | uint8_t step_wheel_pulse(WheelPulseState &state) noexcept | |
| 323 | { | ||
| 324 | 50 | uint8_t mask = 0; | |
| 325 |
2/2✓ Branch 13 → 3 taken 200 times.
✓ Branch 13 → 14 taken 50 times.
|
250 | for (int dir = 0; dir < 4; ++dir) |
| 326 | { | ||
| 327 |
2/2✓ Branch 4 → 5 taken 22 times.
✓ Branch 4 → 7 taken 178 times.
|
200 | if (state.pulsing[dir]) |
| 328 | { | ||
| 329 | // Force one low cycle after a pulse so the edge detector re-arms. | ||
| 330 | 22 | state.pulsing[dir] = false; | |
| 331 | } | ||
| 332 |
2/2✓ Branch 8 → 9 taken 23 times.
✓ Branch 8 → 12 taken 155 times.
|
178 | else if (state.pending[dir] > 0) |
| 333 | { | ||
| 334 | 23 | --state.pending[dir]; | |
| 335 | 23 | mask = static_cast<uint8_t>(mask | (1u << dir)); | |
| 336 | 23 | state.pulsing[dir] = true; | |
| 337 | } | ||
| 338 | } | ||
| 339 | 50 | return mask; | |
| 340 | } | ||
| 341 | |||
| 342 | 111 | void add_wheel_notches(WheelPulseState &state, const std::array<int, 4> &taken) noexcept | |
| 343 | { | ||
| 344 |
2/2✓ Branch 15 → 3 taken 444 times.
✓ Branch 15 → 16 taken 111 times.
|
555 | for (size_t dir = 0; dir < 4; ++dir) |
| 345 | { | ||
| 346 |
2/2✓ Branch 4 → 5 taken 406 times.
✓ Branch 4 → 7 taken 38 times.
|
444 | const int add = taken[dir] > 0 ? taken[dir] : 0; |
| 347 | // pending is in [0, MAX_WHEEL_PENDING] by induction, so room is non-negative. Compare against room before | ||
| 348 | // adding so a large burst saturates rather than overflowing the int sum. | ||
| 349 | 444 | const int room = MAX_WHEEL_PENDING - state.pending[dir]; | |
| 350 |
2/2✓ Branch 9 → 10 taken 42 times.
✓ Branch 9 → 12 taken 402 times.
|
444 | state.pending[dir] = (add >= room) ? MAX_WHEEL_PENDING : state.pending[dir] + add; |
| 351 | } | ||
| 352 | 111 | } | |
| 353 | |||
| 354 | 18 | uint16_t step_gamepad_suppress(GamepadSuppressState &state, uint16_t owned_now, uint16_t true_buttons, | |
| 355 | uint64_t now_ms, uint64_t grace_ms) noexcept | ||
| 356 | { | ||
| 357 | // Sentinel deadline meaning "actively held, not yet releasing". | ||
| 358 | 18 | constexpr uint64_t held_sentinel = UINT64_MAX; | |
| 359 | |||
| 360 | 18 | uint16_t mask = 0; | |
| 361 | 18 | const uint16_t relevant = static_cast<uint16_t>(state.armed | owned_now); | |
| 362 |
2/2✓ Branch 21 → 3 taken 288 times.
✓ Branch 21 → 22 taken 18 times.
|
306 | for (int bit = 0; bit < 16; ++bit) |
| 363 | { | ||
| 364 | 288 | const uint16_t bit_mask = static_cast<uint16_t>(1u << bit); | |
| 365 |
2/2✓ Branch 3 → 4 taken 272 times.
✓ Branch 3 → 5 taken 16 times.
|
288 | if ((relevant & bit_mask) == 0) |
| 366 | { | ||
| 367 | 272 | continue; | |
| 368 | } | ||
| 369 | 16 | const bool phys_down = (true_buttons & bit_mask) != 0; | |
| 370 | 16 | const bool owned = (owned_now & bit_mask) != 0; | |
| 371 | |||
| 372 |
5/6✓ Branch 5 → 6 taken 9 times.
✓ Branch 5 → 8 taken 7 times.
✓ Branch 6 → 7 taken 9 times.
✗ Branch 6 → 10 not taken.
✓ Branch 7 → 8 taken 2 times.
✓ Branch 7 → 10 taken 7 times.
|
16 | if (owned || ((state.armed & bit_mask) != 0 && phys_down)) |
| 373 | { | ||
| 374 | // Actively held: a chord claims it now, or the trigger button is still physically down after the | ||
| 375 | // modifier was released. Keep suppressing and cancel any in-progress release grace. | ||
| 376 | 9 | state.armed = static_cast<uint16_t>(state.armed | bit_mask); | |
| 377 | 9 | state.deadline_ms[static_cast<size_t>(bit)] = held_sentinel; | |
| 378 | 9 | mask = static_cast<uint16_t>(mask | bit_mask); | |
| 379 | } | ||
| 380 |
1/2✓ Branch 10 → 11 taken 7 times.
✗ Branch 10 → 20 not taken.
|
7 | else if ((state.armed & bit_mask) != 0) |
| 381 | { | ||
| 382 | // Armed but the physical button is up: run the release grace so a trailing bare-trigger frame cannot | ||
| 383 | // leak to the game. | ||
| 384 |
2/2✓ Branch 12 → 13 taken 4 times.
✓ Branch 12 → 15 taken 3 times.
|
7 | if (state.deadline_ms[static_cast<size_t>(bit)] == held_sentinel) |
| 385 | { | ||
| 386 | 4 | state.deadline_ms[static_cast<size_t>(bit)] = now_ms + grace_ms; | |
| 387 | } | ||
| 388 |
2/2✓ Branch 16 → 17 taken 4 times.
✓ Branch 16 → 18 taken 3 times.
|
7 | if (now_ms < state.deadline_ms[static_cast<size_t>(bit)]) |
| 389 | { | ||
| 390 | 4 | mask = static_cast<uint16_t>(mask | bit_mask); | |
| 391 | } | ||
| 392 | else | ||
| 393 | { | ||
| 394 | 3 | state.armed = static_cast<uint16_t>(state.armed & static_cast<uint16_t>(~bit_mask)); | |
| 395 | 3 | state.deadline_ms[static_cast<size_t>(bit)] = 0; | |
| 396 | } | ||
| 397 | } | ||
| 398 | } | ||
| 399 | 18 | return mask; | |
| 400 | } | ||
| 401 | |||
| 402 | 34 | uint16_t evaluate_consume_rules(uint16_t true_buttons, const GamepadConsumeRule *rules, std::size_t count) noexcept | |
| 403 | { | ||
| 404 | 34 | uint16_t mask = 0; | |
| 405 |
2/2✓ Branch 7 → 3 taken 44 times.
✓ Branch 7 → 8 taken 34 times.
|
78 | for (std::size_t i = 0; i < count; ++i) |
| 406 | { | ||
| 407 | 44 | const GamepadConsumeRule &rule = rules[i]; | |
| 408 | // Every modifier bit held and no forbidden bit held: the exact decision the poll loop makes (chord | ||
| 409 | // modifiers satisfied and the strict-match check passes), evaluated against the snapshot the game is about | ||
| 410 | // to read. A forbidden bit is a known modifier that belongs to a different chord, so holding one means this | ||
| 411 | // chord is not the active gesture. | ||
| 412 |
4/4✓ Branch 3 → 4 taken 28 times.
✓ Branch 3 → 6 taken 16 times.
✓ Branch 4 → 5 taken 20 times.
✓ Branch 4 → 6 taken 8 times.
|
44 | if ((true_buttons & rule.modifier_mask) == rule.modifier_mask && (true_buttons & rule.forbidden_mask) == 0) |
| 413 | { | ||
| 414 | 20 | mask = static_cast<uint16_t>(mask | rule.trigger_mask); | |
| 415 | } | ||
| 416 | } | ||
| 417 | 34 | return mask; | |
| 418 | } | ||
| 419 | |||
| 420 | 2443 | void publish_gamepad_consume_rules(const GamepadConsumeRule *rules, std::size_t count) noexcept | |
| 421 | { | ||
| 422 | // A list larger than the detour can hold publishes nothing rather than a silent subset: the reactive mask still | ||
| 423 | // covers the held-modifier case, so only the simultaneous-press protection is dropped for that (pathological) | ||
| 424 | // binding set, and the detour never evaluates a partial list. | ||
| 425 |
2/2✓ Branch 2 → 3 taken 1 time.
✓ Branch 2 → 4 taken 2442 times.
|
2443 | if (count > MAX_GAMEPAD_CONSUME_RULES) |
| 426 | { | ||
| 427 | 1 | count = 0; | |
| 428 | } | ||
| 429 | // Seqlock write (single writer). The odd sequence brackets the update so a concurrent detour read sees the | ||
| 430 | // whole new list or skips the frame. The release fence after the odd store keeps the rule stores from being | ||
| 431 | // observed before the bracket opens; the release store of the even sequence publishes the finished list to the | ||
| 432 | // detour's acquire load. | ||
| 433 | 2443 | const uint32_t seq = s_consume_rules_seq.load(std::memory_order_relaxed); | |
| 434 | 2443 | s_consume_rules_seq.store(seq + 1, std::memory_order_relaxed); | |
| 435 | std::atomic_thread_fence(std::memory_order_release); | ||
| 436 |
2/2✓ Branch 32 → 21 taken 8 times.
✓ Branch 32 → 33 taken 2443 times.
|
2451 | for (std::size_t i = 0; i < count; ++i) |
| 437 | { | ||
| 438 | 8 | s_consume_rules[i].store(pack_consume_rule(rules[i]), std::memory_order_relaxed); | |
| 439 | } | ||
| 440 | 2443 | s_consume_rule_count.store(static_cast<uint32_t>(count), std::memory_order_relaxed); | |
| 441 | 2443 | s_consume_rules_seq.store(seq + 2, std::memory_order_release); | |
| 442 | 2443 | } | |
| 443 | |||
| 444 | 18 | uint16_t evaluate_published_consume_rules(uint16_t true_buttons) noexcept | |
| 445 | { | ||
| 446 | // Seqlock read, single attempt (no spin): an odd sequence means the writer is mid-update, and a change across | ||
| 447 | // the copy means the snapshot tore. In either case skip rule masking for this frame (the reactive mask still | ||
| 448 | // applies); the next game poll, microseconds later, gets the settled list. Rules change only on a binding | ||
| 449 | // rebuild, so a torn read is rare and never coincides with steady gameplay input. | ||
| 450 | 18 | const uint32_t seq_before = s_consume_rules_seq.load(std::memory_order_acquire); | |
| 451 |
1/2✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 18 times.
|
18 | if ((seq_before & 1u) != 0) |
| 452 | { | ||
| 453 | ✗ | return 0; | |
| 454 | } | ||
| 455 | 18 | uint32_t count = s_consume_rule_count.load(std::memory_order_relaxed); | |
| 456 |
1/2✗ Branch 18 → 19 not taken.
✓ Branch 18 → 20 taken 18 times.
|
18 | if (count > MAX_GAMEPAD_CONSUME_RULES) |
| 457 | { | ||
| 458 | ✗ | count = MAX_GAMEPAD_CONSUME_RULES; | |
| 459 | } | ||
| 460 | 18 | std::array<GamepadConsumeRule, MAX_GAMEPAD_CONSUME_RULES> snapshot{}; | |
| 461 |
2/2✓ Branch 32 → 21 taken 21 times.
✓ Branch 32 → 33 taken 18 times.
|
39 | for (uint32_t i = 0; i < count; ++i) |
| 462 | { | ||
| 463 | 42 | snapshot[i] = unpack_consume_rule(s_consume_rules[i].load(std::memory_order_relaxed)); | |
| 464 | } | ||
| 465 | // Order the rule loads above before the sequence re-read below, so a writer that updated mid-copy is always | ||
| 466 | // detected. | ||
| 467 | std::atomic_thread_fence(std::memory_order_acquire); | ||
| 468 |
1/2✗ Branch 41 → 42 not taken.
✓ Branch 41 → 43 taken 18 times.
|
18 | if (s_consume_rules_seq.load(std::memory_order_relaxed) != seq_before) |
| 469 | { | ||
| 470 | ✗ | return 0; | |
| 471 | } | ||
| 472 | 36 | return evaluate_consume_rules(true_buttons, snapshot.data(), count); | |
| 473 | } | ||
| 474 | |||
| 475 | ✗ | void set_gamepad_rule_suppress_enabled(bool enabled) noexcept | |
| 476 | { | ||
| 477 | ✗ | s_rule_suppress_enabled.store(enabled, std::memory_order_relaxed); | |
| 478 | ✗ | } | |
| 479 | |||
| 480 | 2 | bool install_xinput(int user_index) noexcept | |
| 481 | { | ||
| 482 | s_bound_user_index.store(user_index, std::memory_order_relaxed); | ||
| 483 |
2/2✓ Branch 11 → 12 taken 1 time.
✓ Branch 11 → 13 taken 1 time.
|
2 | if (s_xinput_installed.load(std::memory_order_acquire)) |
| 484 | { | ||
| 485 | 1 | return true; | |
| 486 | } | ||
| 487 | |||
| 488 | 1 | HMODULE module = nullptr; | |
| 489 |
1/2✓ Branch 18 → 14 taken 1 time.
✗ Branch 18 → 19 not taken.
|
1 | for (const wchar_t *name : XINPUT_DLL_NAMES) |
| 490 | { | ||
| 491 | 1 | module = GetModuleHandleW(name); | |
| 492 |
1/2✓ Branch 15 → 16 taken 1 time.
✗ Branch 15 → 17 not taken.
|
1 | if (module != nullptr) |
| 493 | { | ||
| 494 | 1 | break; | |
| 495 | } | ||
| 496 | } | ||
| 497 |
1/2✗ Branch 19 → 20 not taken.
✓ Branch 19 → 21 taken 1 time.
|
1 | if (module == nullptr) |
| 498 | { | ||
| 499 | ✗ | return false; // XInput not loaded yet; the poll loop retries. | |
| 500 | } | ||
| 501 | |||
| 502 | 1 | auto *get_state = reinterpret_cast<void *>(GetProcAddress(module, "XInputGetState")); | |
| 503 |
1/2✗ Branch 22 → 23 not taken.
✓ Branch 22 → 24 taken 1 time.
|
1 | if (get_state == nullptr) |
| 504 | { | ||
| 505 | ✗ | return false; | |
| 506 | } | ||
| 507 | |||
| 508 | 1 | auto allocator = safetyhook::Allocator::global(); | |
| 509 | // Create the hook disabled so the trampoline exists before the prologue is | ||
| 510 | // patched: publish the original (trampoline) pointer first, then enable. | ||
| 511 | // If the detour went live before the store, a game thread entering it in that window would read a null original | ||
| 512 | // and wrongly report | ||
| 513 | // ERROR_DEVICE_NOT_CONNECTED -- a transient fake disconnect for a frame. | ||
| 514 | auto hook = | ||
| 515 | safetyhook::InlineHook::create(allocator, get_state, reinterpret_cast<void *>(&xinput_get_state_detour), | ||
| 516 | 1 | safetyhook::InlineHook::StartDisabled); | |
| 517 |
1/2✗ Branch 27 → 28 not taken.
✓ Branch 27 → 29 taken 1 time.
|
1 | if (!hook) |
| 518 | { | ||
| 519 | ✗ | return false; | |
| 520 | } | ||
| 521 | 2 | s_xinput_hook = std::move(hook.value()); | |
| 522 | 1 | s_xinput_original.store(s_xinput_hook.original<XInputGetStateFn>(), std::memory_order_release); | |
| 523 |
1/2✗ Branch 37 → 38 not taken.
✓ Branch 37 → 48 taken 1 time.
|
1 | if (!s_xinput_hook.enable()) |
| 524 | { | ||
| 525 | ✗ | s_xinput_original.store(nullptr, std::memory_order_release); | |
| 526 | ✗ | s_xinput_hook = {}; | |
| 527 | ✗ | if (!s_xinput_enable_warned.exchange(true, std::memory_order_relaxed)) | |
| 528 | { | ||
| 529 | ✗ | (void)Logger::get_instance().try_log( | |
| 530 | LogLevel::Warning, | ||
| 531 | "InputIntercept: XInputGetState hook created but enable() failed; gamepad input interception is " | ||
| 532 | "inactive. The poll loop will retry."); | ||
| 533 | } | ||
| 534 | ✗ | return false; | |
| 535 | } | ||
| 536 | |||
| 537 | // XInputGetStateEx (ordinal 100) carries the Guide button; a game that polls it would otherwise bypass the | ||
| 538 | // mask. Hook it too when present; its absence is not an error. Skip it when a proxy/shim xinput DLL aliases the | ||
| 539 | // ordinal to the same code address as XInputGetState: that address is already covered, and a second inline hook | ||
| 540 | // on one prologue would capture the first hook's jmp as its "original" and corrupt the trampoline chain. | ||
| 541 | auto *get_state_ex = | ||
| 542 | 1 | reinterpret_cast<void *>(GetProcAddress(module, MAKEINTRESOURCEA(XINPUT_GET_STATE_EX_ORDINAL))); | |
| 543 |
2/4✓ Branch 49 → 50 taken 1 time.
✗ Branch 49 → 74 not taken.
✓ Branch 50 → 51 taken 1 time.
✗ Branch 50 → 74 not taken.
|
1 | if (get_state_ex != nullptr && get_state_ex != get_state) |
| 544 | { | ||
| 545 | auto ex_hook = safetyhook::InlineHook::create(allocator, get_state_ex, | ||
| 546 | reinterpret_cast<void *>(&xinput_get_state_ex_detour), | ||
| 547 | 1 | safetyhook::InlineHook::StartDisabled); | |
| 548 |
1/2✓ Branch 53 → 54 taken 1 time.
✗ Branch 53 → 72 not taken.
|
1 | if (ex_hook) |
| 549 | { | ||
| 550 | 2 | s_xinput_ex_hook = std::move(ex_hook.value()); | |
| 551 | 1 | s_xinput_ex_original.store(s_xinput_ex_hook.original<XInputGetStateFn>(), std::memory_order_release); | |
| 552 |
1/2✗ Branch 62 → 63 not taken.
✓ Branch 62 → 72 taken 1 time.
|
1 | if (!s_xinput_ex_hook.enable()) |
| 553 | { | ||
| 554 | ✗ | s_xinput_ex_original.store(nullptr, std::memory_order_release); | |
| 555 | ✗ | s_xinput_ex_hook = {}; | |
| 556 | ✗ | if (!s_xinput_ex_enable_warned.exchange(true, std::memory_order_relaxed)) | |
| 557 | { | ||
| 558 | ✗ | (void)Logger::get_instance().try_log( | |
| 559 | LogLevel::Warning, | ||
| 560 | "InputIntercept: XInputGetStateEx (ordinal 100) hook created but enable() failed; the " | ||
| 561 | "Guide button will not be masked. Primary XInput interception remains active."); | ||
| 562 | } | ||
| 563 | } | ||
| 564 | } | ||
| 565 | 1 | } | |
| 566 | |||
| 567 | 1 | s_xinput_installed.store(true, std::memory_order_release); | |
| 568 | 1 | return true; | |
| 569 | 1 | } | |
| 570 | |||
| 571 | 7 | bool xinput_installed() noexcept | |
| 572 | { | ||
| 573 | 7 | return s_xinput_installed.load(std::memory_order_acquire); | |
| 574 | } | ||
| 575 | |||
| 576 | 3 | XInputGetStateFn xinput_trampoline() noexcept | |
| 577 | { | ||
| 578 | 3 | return s_xinput_original.load(std::memory_order_acquire); | |
| 579 | } | ||
| 580 | |||
| 581 | 2 | void publish_gamepad_suppress(uint16_t suppress_bits) noexcept | |
| 582 | { | ||
| 583 | // Write the deadline before the mask (release on the mask). A detour that observes the new mask with acquire is | ||
| 584 | // then guaranteed to also observe the refreshed deadline, so a fresh mask is never paired with a stale | ||
| 585 | // (already-expired) deadline. | ||
| 586 | 2 | s_suppress_deadline_ms.store(GetTickCount64() + SUPPRESS_TTL_MS, std::memory_order_relaxed); | |
| 587 | 2 | s_suppress_mask.store(suppress_bits, std::memory_order_release); | |
| 588 | 2 | } | |
| 589 | |||
| 590 | 8 | bool install_wndproc() noexcept | |
| 591 | { | ||
| 592 |
2/2✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 7 times.
|
8 | if (s_wndproc_installed.load(std::memory_order_acquire)) |
| 593 | { | ||
| 594 | 1 | return true; | |
| 595 | } | ||
| 596 | 7 | const HWND hwnd = find_game_window(); | |
| 597 |
1/2✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 7 times.
|
7 | if (hwnd == nullptr) |
| 598 | { | ||
| 599 | ✗ | return false; // window not available yet; the poll loop retries. | |
| 600 | } | ||
| 601 | |||
| 602 | // Publish the predecessor procedure and target window before the detour goes live. SetWindowLongPtrW makes | ||
| 603 | // wndproc_detour reachable from the window's own message thread the instant it returns; if the predecessor were | ||
| 604 | // stored only afterwards, a message dispatched in that gap would read a zero s_prev_wndproc and route to | ||
| 605 | // DefWindowProcW instead of the game's real procedure. A top-level window always has a non-null WNDPROC, so a | ||
| 606 | // zero read here means the slot is not readable yet. install_wndproc runs only on the single poll thread, so | ||
| 607 | // DMK never races its own install here; a foreign subclasser that installs in the gap between this read and the | ||
| 608 | // swap is reconciled from SetWindowLongPtrW's returned predecessor below. | ||
| 609 | 7 | const LONG_PTR current = GetWindowLongPtrW(hwnd, GWLP_WNDPROC); | |
| 610 |
1/2✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 7 times.
|
7 | if (current == 0) |
| 611 | { | ||
| 612 | ✗ | return false; | |
| 613 | } | ||
| 614 | s_prev_wndproc.store(current, std::memory_order_release); | ||
| 615 | 7 | s_hwnd.store(hwnd, std::memory_order_release); | |
| 616 | |||
| 617 | // SetWindowLongPtr returns the previous value, or 0 on failure. Disambiguate a genuine zero predecessor from an | ||
| 618 | // error via GetLastError. | ||
| 619 | 7 | SetLastError(0); | |
| 620 | 7 | const LONG_PTR prev = SetWindowLongPtrW(hwnd, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(&wndproc_detour)); | |
| 621 |
2/6✗ Branch 22 → 23 not taken.
✓ Branch 22 → 26 taken 7 times.
✗ Branch 24 → 25 not taken.
✗ Branch 24 → 26 not taken.
✗ Branch 27 → 28 not taken.
✓ Branch 27 → 38 taken 7 times.
|
7 | if (prev == 0 && GetLastError() != 0) |
| 622 | { | ||
| 623 | // Swap failed: roll back the published predecessor so no stale handle survives a failed install. | ||
| 624 | ✗ | s_hwnd.store(nullptr, std::memory_order_release); | |
| 625 | s_prev_wndproc.store(0, std::memory_order_release); | ||
| 626 | ✗ | return false; | |
| 627 | } | ||
| 628 | |||
| 629 | // SetWindowLongPtrW returns the WNDPROC it actually displaced. If a foreign subclasser installed itself in the | ||
| 630 | // gap between our GetWindowLongPtrW read and this swap, that returned procedure -- not the predecessor we read | ||
| 631 | // and published -- is the real next link in the chain. Adopt and republish it so wndproc_detour forwards to the | ||
| 632 | // procedure that was on top at swap time, keeping the subclass chain intact rather than silently dropping the | ||
| 633 | // foreign subclasser. The release store pairs with the detour's acquire load of s_prev_wndproc. A genuine zero | ||
| 634 | // predecessor was already rejected as a failure above, so a non-zero mismatch is the only adoption case. | ||
| 635 |
2/4✓ Branch 38 → 39 taken 7 times.
✗ Branch 38 → 49 not taken.
✗ Branch 39 → 40 not taken.
✓ Branch 39 → 49 taken 7 times.
|
7 | if (prev != 0 && prev != current) |
| 636 | { | ||
| 637 | s_prev_wndproc.store(prev, std::memory_order_release); | ||
| 638 | } | ||
| 639 | |||
| 640 | // Drain any notches the wndproc detour latched while no binding owned the wheel. uninstall() drops the consume | ||
| 641 | // flag but leaves the detour live (it may stay layered under a foreign subclass), so it keeps incrementing | ||
| 642 | // s_wheel_count between an unbind and this re-arm. Without this reset the first take_wheel_counts() after a | ||
| 643 | // re-bind would replay that stale backlog as a burst of phantom notches. This is a fresh-install transition | ||
| 644 | // (the idempotent already-installed path returned above), so resetting here cannot discard counts a live | ||
| 645 | // binding is about to consume. | ||
| 646 |
2/2✓ Branch 59 → 50 taken 28 times.
✓ Branch 59 → 60 taken 7 times.
|
35 | for (auto &count : s_wheel_count) |
| 647 | { | ||
| 648 | 28 | count.store(0, std::memory_order_relaxed); | |
| 649 | } | ||
| 650 | |||
| 651 | 7 | s_wndproc_installed.store(true, std::memory_order_release); | |
| 652 | 7 | return true; | |
| 653 | } | ||
| 654 | |||
| 655 | 18 | bool wndproc_installed() noexcept | |
| 656 | { | ||
| 657 | 18 | return s_wndproc_installed.load(std::memory_order_acquire); | |
| 658 | } | ||
| 659 | |||
| 660 | 18 | std::array<int, 4> take_wheel_counts() noexcept | |
| 661 | { | ||
| 662 | 18 | std::array<int, 4> out{}; | |
| 663 |
2/2✓ Branch 8 → 3 taken 72 times.
✓ Branch 8 → 9 taken 18 times.
|
90 | for (int dir = 0; dir < 4; ++dir) |
| 664 | { | ||
| 665 | 72 | out[static_cast<size_t>(dir)] = | |
| 666 | 144 | s_wheel_count[static_cast<size_t>(dir)].exchange(0, std::memory_order_relaxed); | |
| 667 | } | ||
| 668 | 18 | return out; | |
| 669 | } | ||
| 670 | |||
| 671 | 111 | void set_wheel_consume(bool consume) noexcept | |
| 672 | { | ||
| 673 | 111 | s_wheel_consume.store(consume, std::memory_order_relaxed); | |
| 674 | 111 | } | |
| 675 | |||
| 676 | 90 | void uninstall() noexcept | |
| 677 | { | ||
| 678 | // Stop masking before removing the hooks, with single-atomic stores only. Clearing the reactive mask stops | ||
| 679 | // reactive masking and clearing the rule gate stops rule masking. Do NOT seqlock-publish an empty rule list | ||
| 680 | // here: | ||
| 681 | // that is a multi-step write, and a concurrent binding mutation (set_consume | ||
| 682 | // / add_binding, serialized on InputPoller::m_bindings_rw_mutex) is a documented thread-safe call that could | ||
| 683 | // race a second writer and tear the list. The published list is left as is; it is inert once the hooks are gone | ||
| 684 | // and the gate is false, the binding-clear path already empties it under the lock, and a later install | ||
| 685 | // republishes before re-enabling the gate. | ||
| 686 | s_suppress_mask.store(0, std::memory_order_release); | ||
| 687 | 90 | s_rule_suppress_enabled.store(false, std::memory_order_relaxed); | |
| 688 | 90 | s_wheel_consume.store(false, std::memory_order_relaxed); | |
| 689 | |||
| 690 | 90 | uninstall_wndproc(); | |
| 691 | |||
| 692 | // Destroying the safetyhook objects rewrites the patched prologue pages and, under a transient vectored | ||
| 693 | // exception handler, relocates the instruction pointer of any thread caught mid-prologue (no thread is | ||
| 694 | // suspended). The poll thread (the sole reader of the trampolines via xinput_trampoline()) is already joined, | ||
| 695 | // so clearing the saved pointers afterwards races nothing. | ||
| 696 | 90 | s_xinput_ex_hook = {}; | |
| 697 | 90 | s_xinput_hook = {}; | |
| 698 | 90 | s_xinput_ex_original.store(nullptr, std::memory_order_release); | |
| 699 | 90 | s_xinput_original.store(nullptr, std::memory_order_release); | |
| 700 | 90 | s_xinput_installed.store(false, std::memory_order_release); | |
| 701 | // Re-arm the enable()-failure latches so a fresh install after a hot-reload can warn again. | ||
| 702 | 90 | s_xinput_enable_warned.store(false, std::memory_order_relaxed); | |
| 703 | 90 | s_xinput_ex_enable_warned.store(false, std::memory_order_relaxed); | |
| 704 | 90 | } | |
| 705 | |||
| 706 | } // namespace DetourModKit::detail | ||
| 707 |