GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 78.4% 203 / 0 / 259
Functions: 88.0% 22 / 0 / 25
Branches: 61.8% 94 / 0 / 152

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