GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 93.2% 968 / 0 / 1039
Functions: 99.2% 130 / 0 / 131
Branches: 79.8% 498 / 0 / 624

src/internal/input_intercept.cpp
Line Branch Exec Source
1 /**
2 * @file input_intercept.cpp
3 * @brief This TU implements the internal active-input layer from input_intercept.hpp.
4 *
5 * This TU owns the XInputGetState inline hook and the thread-scoped WH_GETMESSAGE wheel hook. They provide gamepad
6 * passthrough suppression and mouse-wheel capture for InputPoller.
7 */
8
9 #include "input_intercept.hpp"
10 #include "internal/hook_patch_witness.hpp"
11 #include "platform.hpp"
12 #include "DetourModKit/diagnostics.hpp"
13 #include "DetourModKit/logger.hpp"
14
15 #include <safetyhook.hpp>
16
17 #include <algorithm>
18 #include <atomic>
19 #include <cstdint>
20 #include <cstring>
21 #include <limits>
22 #include <memory>
23 #include <new>
24 #include <span>
25 #include <string_view>
26 #include <thread>
27 #include <type_traits>
28 #include <utility>
29
30 namespace DetourModKit::detail
31 {
32 namespace
33 {
34 /// The game or runtime determines which DLL contains the XInput export.
35 constexpr const wchar_t *XINPUT_DLL_NAMES[] = {
36 L"xinput1_4.dll",
37 L"xinput1_3.dll",
38 L"xinput9_1_0.dll",
39 L"xinput1_2.dll",
40 L"xinput1_1.dll",
41 };
42
43 /// Identifies the undocumented ordinal that exports XInputGetStateEx and reports the Guide button.
44 constexpr WORD XINPUT_GET_STATE_EX_ORDINAL = 100;
45
46 /**
47 * @brief Defines how long a published suppression mask stays valid without a refresh.
48 * @details Twice MAX_POLL_INTERVAL leaves a full poll interval for cycle work before expiry. A stalled poll
49 * thread still loses suppression after a bounded delay.
50 */
51 constexpr uint64_t SUPPRESS_TTL_MS = 2000;
52
53 // One owner per layer: hooks and keepalives are shared per linked DMK instance, and the token prevents
54 // superseded poller teardown of a newer installation. Static SRWLOCK storage has no destructor, so late
55 // process teardown cannot encounter a destroyed mutex.
56 SRWLOCK s_intercept_mutex = SRWLOCK_INIT;
57 // Makes the owner check and its authorized write one indivisible step. Separate locks let a revoked poller
58 // overwrite the new owner's state. Acquired after s_intercept_mutex wherever both are held. The detours take
59 // neither lock.
60 SRWLOCK s_data_plane_mutex = SRWLOCK_INIT;
61 std::atomic<std::uint64_t> s_intercept_owner{0};
62 std::atomic<std::uint64_t> s_next_intercept_owner{STANDALONE_INTERCEPT_OWNER + 1};
63 constexpr unsigned WHEEL_COUNT_BITS = 11;
64 constexpr std::uint64_t WHEEL_COUNT_MASK = (std::uint64_t{1} << WHEEL_COUNT_BITS) - 1;
65 constexpr std::uint64_t WHEEL_EPOCH_MAX =
66 (std::uint64_t{1} << (std::numeric_limits<std::uint64_t>::digits - WHEEL_COUNT_BITS)) - 1;
67 constexpr std::uint64_t WHEEL_CAPTURE_ENABLED = 1;
68 static_assert(MAX_WHEEL_NOTCHES <= WHEEL_COUNT_MASK);
69
70 188 [[nodiscard]] constexpr std::uint64_t wheel_capture_state(std::uint64_t epoch, bool enabled) noexcept
71 {
72
1/2
✗ Branch 2 → 3 not taken.
✓ Branch 2 → 4 taken 188 times.
188 return (epoch << 1) | (enabled ? WHEEL_CAPTURE_ENABLED : 0);
73 }
74
75 2736 [[nodiscard]] constexpr std::uint64_t wheel_capture_epoch(std::uint64_t state) noexcept
76 {
77 2736 return state >> 1;
78 }
79
80 2189 [[nodiscard]] constexpr std::uint64_t wheel_count_slot(std::uint64_t epoch, std::uint64_t count) noexcept
81 {
82 2189 return (epoch << WHEEL_COUNT_BITS) | count;
83 }
84
85 1549 [[nodiscard]] constexpr std::uint64_t wheel_slot_epoch(std::uint64_t slot) noexcept
86 {
87 1549 return slot >> WHEEL_COUNT_BITS;
88 }
89
90 // The per-axis signed sub-notch remainder includes the capture epoch and consume-ownership state. A fragment
91 // with a different (epoch, owned) tag resets the remainder to zero. A retired epoch fragment and an
92 // owned/unowned fragment pair cannot combine into one notch (WheelDeltaTest.*).
93 constexpr unsigned WHEEL_REMAINDER_BITS = 8;
94 constexpr int WHEEL_REMAINDER_BIAS = 128;
95 constexpr std::uint64_t WHEEL_REMAINDER_VALUE_MASK = (std::uint64_t{1} << WHEEL_REMAINDER_BITS) - 1;
96 constexpr std::uint64_t WHEEL_REMAINDER_OWNED_BIT = std::uint64_t{1} << WHEEL_REMAINDER_BITS;
97 constexpr unsigned WHEEL_REMAINDER_EPOCH_SHIFT = WHEEL_REMAINDER_BITS + 1;
98 static_assert(WHEEL_DELTA < WHEEL_REMAINDER_BIAS, "a sub-notch remainder must fit the biased value field");
99 static_assert(WHEEL_EPOCH_MAX <= (std::numeric_limits<std::uint64_t>::max() >> WHEEL_REMAINDER_EPOCH_SHIFT));
100
101 [[nodiscard]] constexpr std::uint64_t
102 1586 wheel_remainder_slot(std::uint64_t epoch, bool owned, int remainder) noexcept
103 {
104
2/2
✓ Branch 2 → 3 taken 15 times.
✓ Branch 2 → 4 taken 1571 times.
1586 return (epoch << WHEEL_REMAINDER_EPOCH_SHIFT) | (owned ? WHEEL_REMAINDER_OWNED_BIT : 0) |
105 1586 static_cast<std::uint64_t>(remainder + WHEEL_REMAINDER_BIAS);
106 }
107
108 std::atomic<std::uint64_t> s_wheel_capture_state{wheel_capture_state(1, false)};
109 #if defined(DMK_ENABLE_TEST_SEAMS)
110 std::atomic<WheelCaptureEntrySeam> s_wheel_capture_entry_seam{nullptr};
111 std::atomic<WheelFinalizeEntrySeam> s_wheel_finalize_entry_seam{nullptr};
112 std::atomic<XInputRetentionAttributionSeam> s_xinput_retention_attribution_seam{nullptr};
113 std::atomic<std::uint64_t> s_wheel_drain_timeout_override_ms{0};
114 std::atomic<bool> s_force_message_unhook_failure{false};
115 std::atomic<std::int32_t> s_wheel_process_focus_override{-1};
116 #endif
117
118 /** @brief Owns exclusive access to the process-lifetime interception lock. */
119 class InterceptLockGuard
120 {
121 public:
122 1266 explicit InterceptLockGuard(SRWLOCK &mutex) noexcept : m_mutex(mutex) { AcquireSRWLockExclusive(&m_mutex); }
123
124 1266 ~InterceptLockGuard() noexcept
125 {
126
2/2
✓ Branch 2 → 3 taken 1255 times.
✓ Branch 2 → 4 taken 11 times.
1266 if (m_locked)
127 {
128 1255 ReleaseSRWLockExclusive(&m_mutex);
129 }
130 1266 }
131
132 InterceptLockGuard(const InterceptLockGuard &) = delete;
133 InterceptLockGuard &operator=(const InterceptLockGuard &) = delete;
134 InterceptLockGuard(InterceptLockGuard &&) = delete;
135 InterceptLockGuard &operator=(InterceptLockGuard &&) = delete;
136
137 /// Releases the lock before this guard leaves scope.
138 11 void unlock() noexcept
139 {
140
1/2
✓ Branch 2 → 3 taken 11 times.
✗ Branch 2 → 5 not taken.
11 if (m_locked)
141 {
142 11 ReleaseSRWLockExclusive(&m_mutex);
143 11 m_locked = false;
144 }
145 11 }
146
147 private:
148 SRWLOCK &m_mutex;
149 bool m_locked{true};
150 };
151
152 /**
153 * @brief Requires s_intercept_mutex. Reports whether @p owner can claim the layer.
154 * @details This claim predicate admits the unowned layer, which is invalid as write authorization. The
155 * data_plane_authorized() predicate requires an exact owner match.
156 */
157 399 [[nodiscard]] bool owner_available(std::uint64_t owner) noexcept
158 {
159
2/2
✓ Branch 2 → 3 taken 1 time.
✓ Branch 2 → 4 taken 398 times.
399 if (owner == 0)
160 {
161 1 return false;
162 }
163 398 const std::uint64_t current = s_intercept_owner.load(std::memory_order_relaxed);
164
4/4
✓ Branch 11 → 12 taken 208 times.
✓ Branch 11 → 13 taken 190 times.
✓ Branch 12 → 13 taken 175 times.
✓ Branch 12 → 14 taken 33 times.
398 return current == 0 || current == owner;
165 }
166
167 /// Requires s_data_plane_mutex. Reports whether @p owner can write the state that detours read.
168 4349 [[nodiscard]] bool data_plane_authorized(std::uint64_t owner) noexcept
169 {
170
3/4
✓ Branch 2 → 3 taken 4349 times.
✗ Branch 2 → 12 not taken.
✓ Branch 10 → 11 taken 214 times.
✓ Branch 10 → 12 taken 4135 times.
8698 return owner != 0 && s_intercept_owner.load(std::memory_order_relaxed) == owner;
171 }
172
173 #if defined(DMK_ENABLE_TEST_SEAMS)
174 std::atomic<DataPlaneEntrySeam> s_data_plane_entry_seam{nullptr};
175 #endif
176
177 /// Runs the entry probe, if any, before a data-plane operation takes its lock.
178 4349 void run_data_plane_entry_seam() noexcept
179 {
180 #if defined(DMK_ENABLE_TEST_SEAMS)
181
2/2
✓ Branch 3 → 4 taken 6 times.
✓ Branch 3 → 5 taken 4343 times.
4349 if (const DataPlaneEntrySeam seam = s_data_plane_entry_seam.load(std::memory_order_acquire);
182 seam != nullptr)
183 {
184 6 seam();
185 }
186 #endif
187 4349 }
188
189 /** @brief Owns exclusive access to the process-lifetime data-plane lock. */
190 class DataPlaneLockGuard
191 {
192 public:
193 4900 DataPlaneLockGuard() noexcept { AcquireSRWLockExclusive(&s_data_plane_mutex); }
194
195 4900 ~DataPlaneLockGuard() noexcept { ReleaseSRWLockExclusive(&s_data_plane_mutex); }
196
197 DataPlaneLockGuard(const DataPlaneLockGuard &) = delete;
198 DataPlaneLockGuard &operator=(const DataPlaneLockGuard &) = delete;
199 DataPlaneLockGuard(DataPlaneLockGuard &&) = delete;
200 DataPlaneLockGuard &operator=(DataPlaneLockGuard &&) = delete;
201 };
202
203 /// Requires s_data_plane_mutex. Clears every mask and rule that the prior owner armed.
204 void clear_data_plane_locked(std::uint64_t wheel_epoch) noexcept;
205
206 /**
207 * @brief Closes wheel capture and advances its epoch so an already-entered frame cannot write into a
208 * successor.
209 * @return The newly published disabled epoch.
210 */
211 188 [[nodiscard]] std::uint64_t close_wheel_capture_and_advance_epoch() noexcept
212 {
213 188 std::uint64_t state = s_wheel_capture_state.load(std::memory_order_seq_cst);
214 for (;;)
215 {
216 188 const std::uint64_t epoch = wheel_capture_epoch(state);
217
1/2
✓ Branch 11 → 12 taken 188 times.
✗ Branch 11 → 13 not taken.
188 const std::uint64_t next_epoch = epoch == WHEEL_EPOCH_MAX ? 1 : epoch + 1;
218 188 const std::uint64_t desired = wheel_capture_state(next_epoch, false);
219
1/2
✓ Branch 23 → 24 taken 188 times.
✗ Branch 23 → 25 not taken.
188 if (s_wheel_capture_state.compare_exchange_weak(state, desired, std::memory_order_seq_cst))
220 {
221 188 return next_epoch;
222 }
223 }
224 }
225
226 /**
227 * @brief Requires s_intercept_mutex. Publishes the owner after an installation is ready and re-opens wheel
228 * capture for it.
229 * @details The arm action occurs here, not at each install site, so ownership itself controls the association.
230 * An arm with no mounted wheel hook is inert.
231 */
232 363 void publish_owner(std::uint64_t owner) noexcept
233 {
234 363 const DataPlaneLockGuard data_lock;
235 s_intercept_owner.store(owner, std::memory_order_release);
236 s_wheel_capture_state.fetch_or(WHEEL_CAPTURE_ENABLED, std::memory_order_seq_cst);
237 363 }
238
239 /**
240 * @brief Requires s_intercept_mutex. Revokes the layer and clears the data that the prior owner armed.
241 * @details One step ensures an unowned layer retains no live mask that lacks an owner with revoke authority. A
242 * wheel-capture epoch advance invalidates an active message-hook frame without a wait.
243 */
244 182 void revoke_owner_and_clear_data() noexcept
245 {
246 182 const std::uint64_t wheel_epoch = close_wheel_capture_and_advance_epoch();
247 182 const DataPlaneLockGuard data_lock;
248 182 clear_data_plane_locked(wheel_epoch);
249 s_intercept_owner.store(0, std::memory_order_release);
250 182 }
251
252 #if defined(DMK_ENABLE_TEST_SEAMS)
253 // This oracle precedes the hook cell, so its destructor runs after later automatic objects.
254 // The exit proof arms it with a patched target window.
255 constexpr std::size_t XINPUT_PROCESS_EXIT_WITNESS_BYTES = 16;
256 std::atomic<const std::uint8_t *> s_xinput_process_exit_target{nullptr};
257 std::uint8_t s_xinput_process_exit_patch[XINPUT_PROCESS_EXIT_WITNESS_BYTES]{};
258 class XInputProcessExitOracle
259 {
260 public:
261 ~XInputProcessExitOracle() noexcept
262 {
263 const std::uint8_t *const target = s_xinput_process_exit_target.load(std::memory_order_acquire);
264 if (target == nullptr)
265 {
266 return;
267 }
268 for (std::size_t i = 0; i < XINPUT_PROCESS_EXIT_WITNESS_BYTES; ++i)
269 {
270 if (target[i] != s_xinput_process_exit_patch[i])
271 {
272 ::RaiseFailFastException(nullptr, nullptr, 0);
273 }
274 }
275 }
276 };
277 XInputProcessExitOracle s_xinput_process_exit_oracle;
278 #endif
279 std::atomic<XInputGetStateFn> s_xinput_original{nullptr};
280 std::atomic<XInputGetStateFn> s_xinput_ex_original{nullptr};
281 std::atomic<bool> s_xinput_installed{false};
282 // This flag is true while the layer is claimed but a required entry point lacks its patch. Both detours pass
283 // through. This flag still lets the owner poll loop read the primary trampoline directly.
284 std::atomic<bool> s_xinput_pair_degraded{false};
285 // This flag is true after a timeout or unproved restore latches the XInput hooks in process-lifetime storage. A
286 // later Input start re-arms only through the retained primary entry, never over uncertain storage.
287 std::atomic<bool> s_xinput_permanent_detour{false};
288 // One-shot diagnostic latches prevent sink spam because install_xinput retries every poll cycle. uninstall()
289 // clears them so a later hot-reload re-arm can warn again.
290 std::atomic<bool> s_xinput_enable_warned{false};
291 std::atomic<bool> s_xinput_ex_enable_warned{false};
292 std::atomic<bool> s_xinput_capacity_warned{false};
293 // This raw cell owns all XInput hooks and keepalives for the process lifetime.
294 // A normal teardown resets its hooks and releases its module references.
295 // A veto leaves the cell intact, so the CRT runs no InlineHook destructor under the loader lock.
296 struct PermanentXInputHooks
297 {
298 safetyhook::InlineHook primary;
299 safetyhook::InlineHook ex;
300 HMODULE self_ref{nullptr};
301 HMODULE target_ref{nullptr};
302 HMODULE ex_target_ref{nullptr};
303 };
304 alignas(PermanentXInputHooks) unsigned char s_xinput_permanent_cell[sizeof(PermanentXInputHooks)];
305 static_assert(
306 std::is_trivially_destructible_v<decltype(s_xinput_permanent_cell)>,
307 "the raw XInput placement cell must have no automatic destructor"
308 );
309 bool s_xinput_permanent_cell_ready{false};
310 PermanentXInputHooks *s_xinput_permanent_hooks{nullptr};
311 #if defined(DMK_ENABLE_TEST_SEAMS)
312 std::atomic<std::size_t> s_xinput_backend_toggle_exception_catches{0};
313 #endif
314
315 /**
316 * @brief Returns the reserved cell as a live object.
317 * @return Pointer to the object that ensure_permanent_cell() constructed.
318 * @note Requires s_intercept_mutex and a prior successful ensure_permanent_cell().
319 */
320 121 [[nodiscard]] PermanentXInputHooks *permanent_cell() noexcept
321 {
322 121 return std::launder(reinterpret_cast<PermanentXInputHooks *>(s_xinput_permanent_cell));
323 }
324
325 /**
326 * @brief Constructs the reserved cell before an XInput detour is published.
327 * @note Requires s_intercept_mutex. MSVC debug-container proxy setup can allocate, so this runs before the
328 * allocation-free teardown boundary.
329 */
330 81 [[nodiscard]] bool ensure_permanent_cell() noexcept
331 {
332
2/2
✓ Branch 2 → 3 taken 45 times.
✓ Branch 2 → 4 taken 36 times.
81 if (s_xinput_permanent_cell_ready)
333 {
334 45 return true;
335 }
336 try
337 {
338
1/2
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 36 times.
36 ::new (static_cast<void *>(s_xinput_permanent_cell)) PermanentXInputHooks{};
339 }
340 catch (...)
341 {
342 return false;
343 }
344 36 s_xinput_permanent_cell_ready = true;
345 36 s_xinput_permanent_hooks = permanent_cell();
346 36 return true;
347 }
348
349 /**
350 * @brief Identifies the evidence that resets the recovery deadline for an incomplete XInput pair.
351 * @details The delay grows to a cap and stays there (the poll loop asks every cycle, and the target really
352 * can come back). Changed evidence drops the accumulated delay, so recovery occurs before a stale
353 * backoff expires.
354 * @note Requires s_intercept_mutex.
355 */
356 struct XInputRecoveryEvidence
357 {
358 /// Identifies the module whose prologue the pair patches. A different pin identifies a different target.
359 147 const void *target_module{nullptr};
360 /// Identifies the owner that requests recovery. A poller restart supplies fresh evidence.
361 137 std::uint64_t owner{0};
362 /// Identifies the absent member. Either member can be absent, so the recovery cadence keys on both.
363 137 bool primary_covered{false};
364 137 bool ex_covered{false};
365 /**
366 * @brief Records whether each target's current bytes permit the re-arm write.
367 * @details A guarded byte comparison detects when a concurrent writer returns an export. The comparison
368 * does not perform the protection change that the delay bounds.
369 */
370 137 bool primary_target_writable{false};
371 137 bool ex_target_writable{false};
372
373
8/12
✓ Branch 2 → 3 taken 10 times.
✓ Branch 2 → 4 taken 137 times.
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 137 times.
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 137 times.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 137 times.
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 12 taken 137 times.
✓ Branch 12 → 13 taken 1 time.
✓ Branch 12 → 14 taken 136 times.
147 [[nodiscard]] bool operator==(const XInputRecoveryEvidence &) const noexcept = default;
374 };
375
376 /// Defines the first delay after a failed recovery transaction.
377 inline constexpr std::uint64_t XINPUT_RECOVERY_MIN_DELAY_MS = 32;
378 /// Defines the delay ceiling. Recovery retries at this cadence while the pair remains broken.
379 inline constexpr std::uint64_t XINPUT_RECOVERY_MAX_DELAY_MS = 2000;
380
381 XInputRecoveryEvidence s_xinput_recovery_evidence{};
382 std::uint64_t s_xinput_recovery_delay_ms{0};
383 std::uint64_t s_xinput_recovery_not_before_ms{0};
384 #if defined(DMK_ENABLE_TEST_SEAMS)
385 std::atomic<std::size_t> s_xinput_recovery_attempts{0};
386 #endif
387
388 /// Requires s_intercept_mutex. Reports whether a recovery transaction can run for @p evidence now.
389 147 [[nodiscard]] bool xinput_recovery_due(const XInputRecoveryEvidence &evidence) noexcept
390 {
391
2/2
✓ Branch 3 → 4 taken 11 times.
✓ Branch 3 → 5 taken 136 times.
147 if (evidence != s_xinput_recovery_evidence)
392 {
393 11 s_xinput_recovery_evidence = evidence;
394 11 s_xinput_recovery_delay_ms = 0;
395 11 s_xinput_recovery_not_before_ms = 0;
396 11 return true;
397 }
398 136 return GetTickCount64() >= s_xinput_recovery_not_before_ms;
399 }
400
401 /// Requires s_intercept_mutex. Grows the delay toward its cap after a recovery transaction did not complete.
402 14 void xinput_recovery_deferred() noexcept
403 {
404 14 s_xinput_recovery_delay_ms =
405 14 (s_xinput_recovery_delay_ms == 0)
406
2/2
✓ Branch 2 → 3 taken 8 times.
✓ Branch 2 → 6 taken 6 times.
17 ? XINPUT_RECOVERY_MIN_DELAY_MS
407
2/2
✓ Branch 3 → 4 taken 5 times.
✓ Branch 3 → 5 taken 3 times.
8 : (s_xinput_recovery_delay_ms >= XINPUT_RECOVERY_MAX_DELAY_MS / 2 ? XINPUT_RECOVERY_MAX_DELAY_MS
408 5 : s_xinput_recovery_delay_ms * 2);
409 14 s_xinput_recovery_not_before_ms = GetTickCount64() + s_xinput_recovery_delay_ms;
410 14 }
411
412 /// Requires s_intercept_mutex. Clears the gate so the next incomplete pair starts from an immediate attempt.
413 249 void xinput_recovery_reset() noexcept
414 {
415 249 s_xinput_recovery_evidence = {};
416 249 s_xinput_recovery_delay_ms = 0;
417 249 s_xinput_recovery_not_before_ms = 0;
418 249 }
419
420 /// Counts one recovery transaction for the proofs and passes its outcome straight through.
421 20 [[nodiscard]] bool record_xinput_recovery_attempt(bool armed) noexcept
422 {
423 #if defined(DMK_ENABLE_TEST_SEAMS)
424 s_xinput_recovery_attempts.fetch_add(1, std::memory_order_relaxed);
425 #endif
426 20 return armed;
427 }
428
429 std::atomic<int> s_bound_user_index{0};
430 std::atomic<uint16_t> s_suppress_mask{0};
431 std::atomic<uint64_t> s_suppress_deadline_ms{0};
432
433 // This counter tracks game threads inside an XInput detour body. Before hook destruction, uninstall() retires
434 // the published trampoline pointers and drains this counter. This complements SafetyHook's mid-prologue
435 // relocation.
436 std::atomic<int> s_xinput_inflight{0};
437
438 #if defined(DMK_ENABLE_TEST_SEAMS)
439 std::atomic<XInputDetourBodySeam> s_xinput_detour_body_seam{nullptr};
440 std::atomic<XInputArmSeam> s_xinput_arm_seam{nullptr};
441 std::atomic<XInputCleanReleaseSeam> s_xinput_clean_release_seam{nullptr};
442 std::atomic<XInputCreateSeam> s_xinput_create_seam{nullptr};
443 // When set, install_xinput resolves XInputGetState from this module so a test can drive the install against
444 // a synthetic proxy DLL. Set/cleared only on the test thread while no install runs.
445 HMODULE s_xinput_module_override{nullptr};
446 #endif
447
448 /// Runs the arm-boundary probe between the backend toggle and the witness read that judges it.
449 174 void run_xinput_arm_seam() noexcept
450 {
451 #if defined(DMK_ENABLE_TEST_SEAMS)
452
2/2
✓ Branch 3 → 4 taken 16 times.
✓ Branch 3 → 5 taken 158 times.
174 if (const XInputArmSeam seam = s_xinput_arm_seam.load(std::memory_order_acquire); seam != nullptr)
453 {
454 16 seam();
455 }
456 #endif
457 174 }
458
459 /// Balances the install-time keepalives after all detour bodies become inactive.
460 69 void release_xinput_module_refs() noexcept
461 {
462
1/2
✗ Branch 2 → 3 not taken.
✓ Branch 2 → 4 taken 69 times.
69 if (s_xinput_permanent_hooks == nullptr)
463 {
464 return;
465 }
466 69 DetourModKit::detail::release_module_ref(
467 s_xinput_permanent_hooks->ex_target_ref,
468 diagnostics::ModulePinReason::XInputTarget
469 );
470 69 s_xinput_permanent_hooks->ex_target_ref = nullptr;
471 69 DetourModKit::detail::release_module_ref(
472 s_xinput_permanent_hooks->target_ref,
473 diagnostics::ModulePinReason::XInputTarget
474 );
475 69 s_xinput_permanent_hooks->target_ref = nullptr;
476 69 DetourModKit::detail::release_module_ref(
477 s_xinput_permanent_hooks->self_ref,
478 diagnostics::ModulePinReason::XInputKeepalive
479 );
480 69 s_xinput_permanent_hooks->self_ref = nullptr;
481 }
482
483 1315 [[nodiscard]] PatchWitness xinput_patch_witness(const safetyhook::InlineHook &hook) noexcept
484 {
485
1/2
✓ Branch 3 → 4 taken 1315 times.
✗ Branch 3 → 5 not taken.
1315 return hook ? witness_patch(hook) : PatchWitness::Original;
486 }
487
488 289 [[nodiscard]] PatchWitness xinput_teardown_witness(const safetyhook::InlineHook &hook) noexcept
489 {
490 // A reconciled disabled backend has no target entry into its trampoline. Treat retained inactive storage
491 // as Original even if its old target was later repatched or unmapped.
492
4/4
✓ Branch 3 → 4 taken 287 times.
✓ Branch 3 → 7 taken 2 times.
✓ Branch 5 → 6 taken 285 times.
✓ Branch 5 → 7 taken 2 times.
289 return hook && hook.enabled() ? witness_patch(hook) : PatchWitness::Original;
493 }
494
495 174 [[nodiscard]] bool try_xinput_backend_enable(safetyhook::InlineHook &hook) noexcept
496 {
497 try
498 {
499
2/2
✓ Branch 2 → 3 taken 170 times.
✓ Branch 2 → 6 taken 4 times.
174 return hook.enable().has_value();
500 }
501 4 catch (...)
502 {
503 #if defined(DMK_ENABLE_TEST_SEAMS)
504 s_xinput_backend_toggle_exception_catches.fetch_add(1, std::memory_order_relaxed);
505 #endif
506 4 return false;
507 4 }
508 }
509
510 136 [[nodiscard]] bool try_xinput_backend_disable(safetyhook::InlineHook &hook) noexcept
511 {
512 try
513 {
514
2/2
✓ Branch 2 → 3 taken 130 times.
✓ Branch 2 → 6 taken 6 times.
136 return hook.disable().has_value();
515 }
516 6 catch (...)
517 {
518 #if defined(DMK_ENABLE_TEST_SEAMS)
519 s_xinput_backend_toggle_exception_catches.fetch_add(1, std::memory_order_relaxed);
520 #endif
521 6 return false;
522 6 }
523 }
524
525 /**
526 * @brief Classifies one raw-hook arm by whether a prologue mutation reached the target.
527 * @details Uncommitted is the only outcome whose storage the caller can destroy. CommittedUnreachable wrote
528 * the prologue and then lost it. A thread already inside can still reach the trampoline.
529 */
530 enum class XInputArmOutcome : std::uint8_t
531 {
532 Armed,
533 Uncommitted,
534 CommittedUnreachable
535 };
536
537 /** @brief Arms a freshly created raw hook and reconciles a failed transaction from its target bytes. */
538 175 [[nodiscard]] XInputArmOutcome arm_xinput_hook(
539 safetyhook::InlineHook &hook,
540 std::atomic<XInputGetStateFn> &original,
541 bool original_was_published,
542 std::atomic<bool> &warning_latch,
543 std::string_view warning
544 ) noexcept
545 {
546 175 const PatchWitness before = xinput_patch_witness(hook);
547
2/2
✓ Branch 4 → 5 taken 1 time.
✓ Branch 4 → 9 taken 174 times.
175 if (!witness_permits_write(before))
548 {
549 1 hook.reconcile_enabled(false);
550
1/2
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 1 time.
1 if (!original_was_published)
551 {
552 original.store(nullptr, std::memory_order_release);
553 }
554 1 return XInputArmOutcome::Uncommitted;
555 }
556
557 174 const bool backend_enabled = try_xinput_backend_enable(hook);
558 174 run_xinput_arm_seam();
559 174 const bool mutation_committed = hook.enabled();
560 174 const PatchWitness after = xinput_patch_witness(hook);
561
4/4
✓ Branch 13 → 14 taken 171 times.
✓ Branch 13 → 15 taken 3 times.
✓ Branch 14 → 15 taken 12 times.
✓ Branch 14 → 32 taken 159 times.
174 if (!mutation_committed || after == PatchWitness::Original)
562 {
563 15 hook.reconcile_enabled(false);
564
4/4
✓ Branch 16 → 17 taken 3 times.
✓ Branch 16 → 19 taken 12 times.
✓ Branch 17 → 18 taken 2 times.
✓ Branch 17 → 19 taken 1 time.
15 if (!mutation_committed && !original_was_published)
565 {
566 2 original.store(nullptr, std::memory_order_release);
567 }
568
5/6
✓ Branch 19 → 20 taken 12 times.
✓ Branch 19 → 21 taken 3 times.
✗ Branch 20 → 21 not taken.
✓ Branch 20 → 24 taken 12 times.
✓ Branch 25 → 26 taken 3 times.
✓ Branch 25 → 28 taken 12 times.
18 if ((!backend_enabled || after != PatchWitness::Original) &&
569
1/2
✓ Branch 22 → 23 taken 3 times.
✗ Branch 22 → 24 not taken.
3 !warning_latch.exchange(true, std::memory_order_relaxed))
570 {
571 3 (void)log().log_noexcept(LogLevel::Warning, warning);
572 }
573 // A committed mutation routed callers through this trampoline before another writer restored the
574 // bytes. No new caller can arrive, but this code still must not free the storage.
575
2/2
✓ Branch 28 → 29 taken 12 times.
✓ Branch 28 → 30 taken 3 times.
15 return mutation_committed ? XInputArmOutcome::CommittedUnreachable : XInputArmOutcome::Uncommitted;
576 }
577
578 // OwnedPatch is exact reachability. Foreign and Indeterminate cannot disprove a newer chain through this
579 // trampoline, so they retain the same conservative enabled state.
580 159 hook.reconcile_enabled(true);
581
5/6
✓ Branch 33 → 34 taken 158 times.
✓ Branch 33 → 35 taken 1 time.
✗ Branch 34 → 35 not taken.
✓ Branch 34 → 38 taken 158 times.
✓ Branch 39 → 40 taken 1 time.
✓ Branch 39 → 42 taken 158 times.
160 if ((!backend_enabled || after != PatchWitness::OwnedPatch) &&
582
1/2
✓ Branch 36 → 37 taken 1 time.
✗ Branch 36 → 38 not taken.
1 !warning_latch.exchange(true, std::memory_order_relaxed))
583 {
584 1 (void)log().log_noexcept(LogLevel::Warning, warning);
585 }
586 159 return XInputArmOutcome::Armed;
587 }
588
589 /**
590 * @brief Creates one disabled raw hook and contains allocation exceptions from creation.
591 * @details Creation remains separate from the arm, so the whole pair exists before any prologue patch. A
592 * creation failure rolls back completely and publishes nothing.
593 */
594 156 [[nodiscard]] bool create_disabled_xinput_hook(
595 safetyhook::RouteRetentionCredit &credit,
596 void *target,
597 void *detour,
598 safetyhook::InlineHook &destination
599 ) noexcept
600 {
601 try
602 {
603 // One fresh arena per pair member keeps the pre-reserved block worst case independent.
604
2/2
✓ Branch 2 → 3 taken 155 times.
✓ Branch 2 → 23 taken 1 time.
156 const std::shared_ptr<safetyhook::Allocator> allocator = safetyhook::Allocator::create();
605 #if defined(DMK_ENABLE_TEST_SEAMS)
606
2/2
✓ Branch 4 → 5 taken 1 time.
✓ Branch 4 → 6 taken 154 times.
155 if (const XInputCreateSeam seam = s_xinput_create_seam.load(std::memory_order_acquire); seam != nullptr)
607 {
608 1 seam();
609 }
610 #endif
611 auto created = safetyhook::InlineHook::create(
612 allocator,
613 target,
614 detour,
615 static_cast<safetyhook::InlineHook::Flags>(
616 safetyhook::InlineHook::StartDisabled | safetyhook::InlineHook::RoutedExternal
617 ),
618 credit
619
2/2
✓ Branch 6 → 7 taken 154 times.
✓ Branch 6 → 21 taken 1 time.
155 );
620
1/2
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 154 times.
154 if (!created)
621 {
622 return false;
623 }
624 // The move assigns onto an empty destination, so it frees nothing and takes no allocation.
625
1/2
✓ Branch 10 → 11 taken 154 times.
✗ Branch 10 → 19 not taken.
308 destination = std::move(created.value());
626 154 return true;
627 155 }
628 2 catch (...)
629 {
630 2 return false;
631 2 }
632 }
633
634 /** @brief Restores one raw hook only while its pre-write witness authorizes the backend mutation. */
635 139 [[nodiscard]] PatchWitness restore_xinput_hook(safetyhook::InlineHook &hook) noexcept
636 {
637
2/2
✓ Branch 3 → 4 taken 2 times.
✓ Branch 3 → 5 taken 137 times.
139 if (!hook)
638 {
639 2 return PatchWitness::Original;
640 }
641
2/2
✓ Branch 6 → 7 taken 1 time.
✓ Branch 6 → 8 taken 136 times.
137 if (!hook.enabled())
642 {
643 // A disabled backend has no target entry and writes nothing. A byte check at its former target reports
644 // Foreign for an unrelated writer and forces needless permanent retention.
645 1 return PatchWitness::Original;
646 }
647 136 const PatchWitness before = xinput_teardown_witness(hook);
648
1/2
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 13 taken 136 times.
136 if (!witness_permits_write(before))
649 {
650 hook.reconcile_enabled(true);
651 return before;
652 }
653
654 136 (void)try_xinput_backend_disable(hook);
655 136 const PatchWitness after = xinput_patch_witness(hook);
656 136 hook.reconcile_enabled(after != PatchWitness::Original);
657 136 return after;
658 }
659
660 /**
661 * @brief Releases a hook after its target witnesses Original and its detour bodies drain.
662 * @note This noexcept move release performs no allocation. Published stable gateways remain process-lifetime
663 * storage by design.
664 */
665 132 void reset_inactive_xinput_hook(safetyhook::InlineHook &hook, std::atomic<XInputGetStateFn> &original) noexcept
666 {
667 132 original.store(nullptr, std::memory_order_seq_cst);
668 132 hook = {};
669 132 }
670
671 /**
672 * @brief Publishes a trampoline, then arms a hook whose prologue is not patched.
673 * @details Reuses the hook object and its trampoline, so nothing is created and no executable storage is
674 * freed or replaced. arm_xinput_hook's witnesses still gate the write and decide reachability.
675 */
676 175 [[nodiscard]] XInputArmOutcome arm_created_xinput_hook(
677 safetyhook::InlineHook &hook,
678 std::atomic<XInputGetStateFn> &original,
679 std::atomic<bool> &warning_latch,
680 std::string_view warning
681 ) noexcept
682 {
683
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 175 times.
175 if (!hook)
684 {
685 return XInputArmOutcome::Uncommitted;
686 }
687
1/2
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 175 times.
175 if (hook.enabled())
688 {
689 return XInputArmOutcome::Armed;
690 }
691 175 const bool original_was_published = original.load(std::memory_order_seq_cst) != nullptr;
692 175 original.store(hook.original<XInputGetStateFn>(), std::memory_order_seq_cst);
693 175 return arm_xinput_hook(hook, original, original_was_published, warning_latch, warning);
694 }
695
696 /// Reports whether rearm restores the forward path.
697 23 [[nodiscard]] bool rearm_xinput_hook(
698 safetyhook::InlineHook &hook,
699 std::atomic<XInputGetStateFn> &original,
700 std::atomic<bool> &warning_latch,
701 std::string_view warning
702 ) noexcept
703 {
704 23 return arm_created_xinput_hook(hook, original, warning_latch, warning) == XInputArmOutcome::Armed;
705 }
706
707 enum class XInputRetentionReason : std::uint8_t
708 {
709 InflightTimeout,
710 UnrestoredPatch,
711 UnprovedInstall
712 };
713
714 struct XInputPublishedChains
715 {
716 bool primary{false};
717 bool ex{false};
718 };
719
720 struct XInputRetentionLog
721 {
722 std::array<char, 256> attribution{};
723 std::size_t attribution_length{0};
724 XInputRetentionReason reason{XInputRetentionReason::InflightTimeout};
725 bool pending{false};
726 };
727
728 /// Appends @p text at @p length and truncates at the buffer end.
729 143 void append_text(std::span<char> buffer, std::size_t &length, std::string_view text) noexcept
730 {
731 143 const std::size_t room = buffer.size() - length;
732
1/2
✓ Branch 4 → 5 taken 143 times.
✗ Branch 4 → 6 not taken.
143 const std::size_t count = text.size() < room ? text.size() : room;
733 143 std::memcpy(buffer.data() + length, text.data(), count);
734 143 length += count;
735 143 }
736
737 /// Appends "0x" and the fixed 16-digit hex form of @p value.
738 22 void append_hex_address(std::span<char> buffer, std::size_t &length, std::uintptr_t value) noexcept
739 {
740 22 append_text(buffer, length, "0x");
741 char digits[16];
742
2/2
✓ Branch 6 → 5 taken 352 times.
✓ Branch 6 → 7 taken 22 times.
374 for (int i = 15; i >= 0; --i)
743 {
744 352 digits[i] = "0123456789ABCDEF"[value & 0xFU];
745 352 value >>= 4U;
746 }
747 22 append_text(buffer, length, std::string_view{digits, sizeof(digits)});
748 22 }
749
750 /// Names the condition that forced the raw hook pair into permanent storage.
751 11 [[nodiscard]] constexpr std::string_view xinput_retention_message(XInputRetentionReason reason) noexcept
752 {
753
3/4
✓ Branch 2 → 3 taken 2 times.
✓ Branch 2 → 4 taken 8 times.
✓ Branch 2 → 5 taken 1 time.
✗ Branch 2 → 6 not taken.
11 switch (reason)
754 {
755 2 case XInputRetentionReason::InflightTimeout:
756 return "XInput interception: a game thread was still inside a detour at the quiesce deadline; "
757 2 "retained the hook trampolines instead of freeing them.";
758 8 case XInputRetentionReason::UnrestoredPatch:
759 return "XInput interception: target bytes could not be proved restored; retained the raw hook chain "
760 8 "instead of overwriting or freeing it.";
761 1 case XInputRetentionReason::UnprovedInstall:
762 1 break;
763 }
764 return "XInput interception: the prologue reverted to its original bytes after the hook transaction "
765 1 "committed; retained the published trampoline instead of freeing it.";
766 }
767
768 /**
769 * @brief Latches the canonical raw hook pair and keepalives as permanently retained.
770 * @note Requires s_intercept_mutex and a constructed process-lifetime cell.
771 * A supplied publication snapshot must be exact.
772 * Omit the snapshot only when a successful drain preceded the retention decision.
773 * Forwarding state then selects the published chains on its own.
774 */
775 11 void retain_xinput_hooks(
776 PatchWitness primary_witness,
777 PatchWitness ex_witness,
778 XInputRetentionReason reason,
779 XInputRetentionLog &deferred_log,
780 XInputPublishedChains published_chains = {}
781 ) noexcept
782 {
783 11 PermanentXInputHooks *const permanent = permanent_cell();
784 11 const bool primary_valid = static_cast<bool>(permanent->primary);
785 11 const bool ex_valid = static_cast<bool>(permanent->ex);
786
3/4
✓ Branch 5 → 6 taken 11 times.
✗ Branch 5 → 9 not taken.
✓ Branch 7 → 8 taken 9 times.
✓ Branch 7 → 9 taken 2 times.
11 const bool primary_forwarding_required = primary_valid && permanent->primary.enabled();
787
3/4
✓ Branch 10 → 11 taken 11 times.
✗ Branch 10 → 14 not taken.
✓ Branch 12 → 13 taken 9 times.
✓ Branch 12 → 14 taken 2 times.
11 const bool ex_forwarding_required = ex_valid && permanent->ex.enabled();
788 11 permanent->primary.reconcile_enabled(
789
3/4
✓ Branch 15 → 16 taken 9 times.
✓ Branch 15 → 18 taken 2 times.
✓ Branch 16 → 17 taken 9 times.
✗ Branch 16 → 18 not taken.
11 primary_forwarding_required && primary_witness != PatchWitness::Original
790 );
791
3/4
✓ Branch 20 → 21 taken 9 times.
✓ Branch 20 → 23 taken 2 times.
✓ Branch 21 → 22 taken 9 times.
✗ Branch 21 → 23 not taken.
11 permanent->ex.reconcile_enabled(ex_forwarding_required && ex_witness != PatchWitness::Original);
792
793 // Keep a chain published when the backend still forwards. Also keep it when pointer publication preceded
794 // the retention decision. An admitted caller can still reach the detour's original-pointer load.
795 11 const bool primary_chain_required =
796
4/6
✓ Branch 25 → 26 taken 2 times.
✓ Branch 25 → 28 taken 9 times.
✓ Branch 26 → 27 taken 2 times.
✗ Branch 26 → 29 not taken.
✓ Branch 27 → 28 taken 2 times.
✗ Branch 27 → 29 not taken.
11 primary_forwarding_required || (primary_valid && published_chains.primary);
797
4/6
✓ Branch 30 → 31 taken 2 times.
✓ Branch 30 → 33 taken 9 times.
✓ Branch 31 → 32 taken 2 times.
✗ Branch 31 → 34 not taken.
✗ Branch 32 → 33 not taken.
✓ Branch 32 → 34 taken 2 times.
11 const bool ex_chain_required = ex_forwarding_required || (ex_valid && published_chains.ex);
798
1/2
✓ Branch 35 → 36 taken 11 times.
✗ Branch 35 → 37 not taken.
22 s_xinput_original.store(
799 11 primary_chain_required ? permanent->primary.original<XInputGetStateFn>() : nullptr,
800 std::memory_order_seq_cst
801 );
802
2/2
✓ Branch 39 → 40 taken 9 times.
✓ Branch 39 → 41 taken 2 times.
20 s_xinput_ex_original.store(
803 9 ex_chain_required ? permanent->ex.original<XInputGetStateFn>() : nullptr,
804 std::memory_order_seq_cst
805 );
806 11 s_xinput_permanent_detour.store(true, std::memory_order_release);
807 11 s_xinput_installed.store(false, std::memory_order_release);
808 11 DetourModKit::diagnostics::record_intentional_leak(DetourModKit::diagnostics::LeakSubsystem::Input);
809
810 // Build the loader attribution under the lock. The caller emits it after the critical section.
811 11 deferred_log.reason = reason;
812 11 append_text(
813 11 deferred_log.attribution,
814 11 deferred_log.attribution_length,
815 11 "XInput retention attribution: XInputGetState target "
816 );
817
1/2
✓ Branch 50 → 51 taken 11 times.
✗ Branch 50 → 52 not taken.
22 append_hex_address(
818 11 deferred_log.attribution,
819 11 deferred_log.attribution_length,
820 22 permanent->primary ? permanent->primary.target_address() : std::uintptr_t{0}
821 );
822 11 append_text(deferred_log.attribution, deferred_log.attribution_length, " (");
823 22 append_text(
824 11 deferred_log.attribution,
825 11 deferred_log.attribution_length,
826 witness_description(primary_witness)
827 );
828 11 append_text(deferred_log.attribution, deferred_log.attribution_length, ")");
829
1/2
✓ Branch 65 → 66 taken 11 times.
✗ Branch 65 → 82 not taken.
11 if (permanent->ex)
830 {
831 11 append_text(deferred_log.attribution, deferred_log.attribution_length, ". XInputGetStateEx target ");
832 22 append_hex_address(
833 11 deferred_log.attribution,
834 11 deferred_log.attribution_length,
835 permanent->ex.target_address()
836 );
837 11 append_text(deferred_log.attribution, deferred_log.attribution_length, " (");
838 11 append_text(deferred_log.attribution, deferred_log.attribution_length, witness_description(ex_witness));
839 11 append_text(deferred_log.attribution, deferred_log.attribution_length, ")");
840 }
841 11 append_text(deferred_log.attribution, deferred_log.attribution_length, ".");
842 11 deferred_log.pending = true;
843
844 11 s_xinput_pair_degraded.store(false, std::memory_order_release);
845 11 xinput_recovery_reset();
846 11 s_xinput_enable_warned.store(false, std::memory_order_relaxed);
847 11 s_xinput_ex_enable_warned.store(false, std::memory_order_relaxed);
848 11 s_xinput_capacity_warned.store(false, std::memory_order_relaxed);
849 11 }
850
851 /** @brief Emits a retention report after its caller releases s_intercept_mutex. */
852 11 void emit_xinput_retention_log(const XInputRetentionLog &deferred_log) noexcept
853 {
854
1/2
✗ Branch 2 → 3 not taken.
✓ Branch 2 → 4 taken 11 times.
11 if (!deferred_log.pending)
855 {
856 return;
857 }
858 11 const std::string_view attribution{deferred_log.attribution.data(), deferred_log.attribution_length};
859 #if defined(DMK_ENABLE_TEST_SEAMS)
860
2/2
✓ Branch 7 → 8 taken 1 time.
✓ Branch 7 → 9 taken 10 times.
11 if (const XInputRetentionAttributionSeam seam =
861 11 s_xinput_retention_attribution_seam.load(std::memory_order_acquire);
862 seam != nullptr)
863 {
864 1 seam(attribution);
865 }
866 #endif
867 11 (void)log().log_noexcept(LogLevel::Warning, xinput_retention_message(deferred_log.reason));
868 11 (void)log().log_noexcept(LogLevel::Warning, attribution);
869 }
870
871 /**
872 * @brief Requires s_intercept_mutex. Commits a complete XInput pair and activates suppression.
873 * @details Both detours read s_xinput_installed, so suppression can never be live for one member of the
874 * pair and not the other.
875 */
876 93 void publish_complete_xinput_pair(int user_index, std::uint64_t owner) noexcept
877 {
878 s_bound_user_index.store(user_index, std::memory_order_relaxed);
879 93 xinput_recovery_reset();
880 93 s_xinput_pair_degraded.store(false, std::memory_order_release);
881 93 s_xinput_installed.store(true, std::memory_order_release);
882 93 publish_owner(owner);
883 93 }
884
885 /**
886 * @brief Requires s_intercept_mutex. Reads one pair member's target bytes and reports whether it still covers.
887 * @param required Whether this entry point needs coverage for a complete pair. An absent or aliased
888 * ordinal-100 export needs no second member.
889 */
890 335 [[nodiscard]] bool xinput_member_entry_witnessed(const safetyhook::InlineHook &hook, bool required) noexcept
891 {
892
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 335 times.
335 if (!hook)
893 {
894 return !required;
895 }
896
2/2
✓ Branch 6 → 7 taken 159 times.
✓ Branch 6 → 8 taken 176 times.
335 if (!hook.enabled())
897 {
898 159 return false;
899 }
900 176 return xinput_patch_witness(hook) == PatchWitness::OwnedPatch;
901 }
902
903 /** @brief Reports exact owned coverage and reconciles Original to disabled under s_intercept_mutex. */
904 524 [[nodiscard]] bool xinput_member_covers_entry(safetyhook::InlineHook &hook, bool required) noexcept
905 {
906
2/2
✓ Branch 3 → 4 taken 2 times.
✓ Branch 3 → 5 taken 522 times.
524 if (!hook)
907 {
908 2 return !required;
909 }
910
2/2
✓ Branch 6 → 7 taken 162 times.
✓ Branch 6 → 8 taken 360 times.
522 if (!hook.enabled())
911 {
912 162 return false;
913 }
914 360 const PatchWitness witness = xinput_patch_witness(hook);
915
2/2
✓ Branch 9 → 10 taken 351 times.
✓ Branch 9 → 11 taken 9 times.
360 if (witness == PatchWitness::OwnedPatch)
916 {
917 351 return true;
918 }
919 // Exact Original can be re-armed through the same object. Foreign and Indeterminate can still be a
920 // newer chain through our trampoline, so preserve their enabled state and report only coverage loss.
921
2/2
✓ Branch 11 → 12 taken 5 times.
✓ Branch 11 → 13 taken 4 times.
9 if (witness == PatchWitness::Original)
922 {
923 5 hook.reconcile_enabled(false);
924 }
925 9 return false;
926 }
927
928 /**
929 * @brief Requires s_intercept_mutex. Publishes complete coverage only when a final witness proves both members.
930 * @details If an owned patch disappears, complete state clears first. No detour masks while the other entry
931 * point bypasses. The layer remains claimed and degraded.
932 * @return true when coverage was published.
933 */
934 242 [[nodiscard]] bool publish_xinput_pair_if_whole(
935 safetyhook::InlineHook &primary,
936 safetyhook::InlineHook &ex,
937 int user_index,
938 std::uint64_t owner
939 ) noexcept
940 {
941 242 const bool primary_covered = xinput_member_covers_entry(primary, true);
942 242 const bool ex_covered = xinput_member_covers_entry(ex, static_cast<bool>(ex));
943
4/4
✓ Branch 5 → 6 taken 233 times.
✓ Branch 5 → 9 taken 9 times.
✓ Branch 6 → 7 taken 93 times.
✓ Branch 6 → 9 taken 140 times.
242 if (primary_covered && ex_covered)
944 {
945 93 publish_complete_xinput_pair(user_index, owner);
946 93 return true;
947 }
948 s_bound_user_index.store(user_index, std::memory_order_relaxed);
949 149 s_xinput_installed.store(false, std::memory_order_release);
950 149 s_xinput_pair_degraded.store(true, std::memory_order_release);
951 149 publish_owner(owner);
952 149 return false;
953 }
954
955 /**
956 * @brief Requires s_intercept_mutex. Re-arms each member that lost entry-point coverage.
957 * @details Each member uses its own hook object. Recovery of only the partner leaves the required export
958 * permanently open.
959 */
960 20 [[nodiscard]] bool rearm_xinput_pair(safetyhook::InlineHook &primary, safetyhook::InlineHook &ex) noexcept
961 {
962 20 bool primary_covered = xinput_member_covers_entry(primary, true);
963
6/6
✓ Branch 3 → 4 taken 8 times.
✓ Branch 3 → 7 taken 12 times.
✓ Branch 5 → 6 taken 6 times.
✓ Branch 5 → 7 taken 2 times.
✓ Branch 8 → 9 taken 6 times.
✓ Branch 8 → 12 taken 14 times.
20 if (!primary_covered && !primary.enabled())
964 {
965 6 (void)rearm_xinput_hook(
966 primary,
967 s_xinput_original,
968 s_xinput_enable_warned,
969 6 "InputIntercept: the XInputGetState re-arm did not complete cleanly, so XInput "
970 "coverage stays degraded and both entries pass through."
971 );
972 6 primary_covered = xinput_member_entry_witnessed(primary, true);
973 }
974 20 bool ex_covered = xinput_member_covers_entry(ex, static_cast<bool>(ex));
975
5/6
✓ Branch 14 → 15 taken 13 times.
✓ Branch 14 → 18 taken 7 times.
✓ Branch 16 → 17 taken 13 times.
✗ Branch 16 → 18 not taken.
✓ Branch 19 → 20 taken 13 times.
✓ Branch 19 → 24 taken 7 times.
20 if (!ex_covered && !ex.enabled())
976 {
977 13 (void)rearm_xinput_hook(
978 ex,
979 s_xinput_ex_original,
980 s_xinput_ex_enable_warned,
981 13 "InputIntercept: the XInputGetStateEx re-arm did not complete cleanly, so "
982 "XInput coverage stays degraded and both entries pass through."
983 );
984 13 ex_covered = xinput_member_entry_witnessed(ex, static_cast<bool>(ex));
985 }
986
4/4
✓ Branch 24 → 25 taken 17 times.
✓ Branch 24 → 27 taken 3 times.
✓ Branch 25 → 26 taken 6 times.
✓ Branch 25 → 27 taken 11 times.
20 return primary_covered && ex_covered;
987 }
988
989 /**
990 * @brief Requires s_intercept_mutex. Re-witnesses a pair and drives deadline-gated recovery of absent members.
991 * @details One routine handles per-cycle health, live-pair recovery, and retained-pair recovery. It detects
992 * loss of pair integrity after publication. Every publication uses the final pair witness.
993 * @return true when complete coverage is published for @p owner.
994 */
995 160 [[nodiscard]] bool maintain_xinput_pair(
996 safetyhook::InlineHook &primary,
997 safetyhook::InlineHook &ex,
998 const void *target_module,
999 int user_index,
1000 std::uint64_t owner
1001 ) noexcept
1002 {
1003
2/2
✓ Branch 3 → 4 taken 13 times.
✓ Branch 3 → 5 taken 147 times.
160 if (publish_xinput_pair_if_whole(primary, ex, user_index, owner))
1004 {
1005 13 return true;
1006 }
1007
1008 const XInputRecoveryEvidence evidence{
1009 target_module,
1010 owner,
1011 147 xinput_member_entry_witnessed(primary, true),
1012 147 xinput_member_entry_witnessed(ex, static_cast<bool>(ex)),
1013 147 witness_permits_write(xinput_patch_witness(primary)),
1014 147 witness_permits_write(xinput_patch_witness(ex))
1015 441 };
1016
2/2
✓ Branch 13 → 14 taken 127 times.
✓ Branch 13 → 15 taken 20 times.
147 if (!xinput_recovery_due(evidence))
1017 {
1018 127 return false;
1019 }
1020
5/6
✓ Branch 17 → 18 taken 6 times.
✓ Branch 17 → 21 taken 14 times.
✓ Branch 19 → 20 taken 6 times.
✗ Branch 19 → 21 not taken.
✓ Branch 22 → 23 taken 6 times.
✓ Branch 22 → 24 taken 14 times.
26 if (record_xinput_recovery_attempt(rearm_xinput_pair(primary, ex)) &&
1021 6 publish_xinput_pair_if_whole(primary, ex, user_index, owner))
1022 {
1023 6 return true;
1024 }
1025 14 xinput_recovery_deferred();
1026 14 return false;
1027 }
1028
1029 /**
1030 * @brief Marks a game thread as active inside an XInput detour body.
1031 * @details This counter and the published trampoline pointer form a Dekker-style pair with uninstall()'s
1032 * retire-store-then-drain-load. Both sides use store then load. Acquire and release do not forbid
1033 * this StoreLoad order change. The increment, trampoline load, retire store, and drain load use
1034 * seq_cst. The decrement stays release and does not belong to the StoreLoad pair.
1035 */
1036 struct InflightGuard
1037 {
1038 7591 InflightGuard() noexcept { s_xinput_inflight.fetch_add(1, std::memory_order_seq_cst); }
1039 7591 ~InflightGuard() noexcept { s_xinput_inflight.fetch_sub(1, std::memory_order_release); }
1040 InflightGuard(const InflightGuard &) = delete;
1041 InflightGuard &operator=(const InflightGuard &) = delete;
1042 InflightGuard(InflightGuard &&) = delete;
1043 InflightGuard &operator=(InflightGuard &&) = delete;
1044 };
1045
1046 // Each detour-side consume rule occupies one atomic word, so readers never see a torn rule. A seqlock protects
1047 // the array and count. An even value is stable, and an odd value marks an update. Game XInput threads read
1048 // snapshots without a lock. The two writers, clear_data_plane_locked() and publish_gamepad_consume_rules(),
1049 // serialize through s_data_plane_mutex. InputPoller publication also holds InputPoller::m_bindings_rw_mutex.
1050 std::array<std::atomic<uint64_t>, MAX_GAMEPAD_CONSUME_RULES> s_consume_rules{};
1051 std::atomic<uint32_t> s_consume_rule_count{0};
1052 std::atomic<uint32_t> s_consume_rules_seq{0};
1053
1054 // This gate controls detour-side rule suppression and refreshes every poll cycle. The rule list and its TTL
1055 // survive focus changes. Without this gate, apply_suppress continues suppression while the mod is unfocused.
1056 std::atomic<bool> s_rule_suppress_enabled{false};
1057
1058 /**
1059 * @brief Packs a rule into one word: modifier (bits 0-15), forbidden (16-31), trigger (32-47).
1060 * @details Three 16-bit masks fit a uint64 with room to spare, so a rule is published and read as a single
1061 * atomic store/load.
1062 */
1063 181 constexpr uint64_t pack_consume_rule(const GamepadConsumeRule &rule) noexcept
1064 {
1065 181 return static_cast<uint64_t>(rule.modifier_mask) | (static_cast<uint64_t>(rule.forbidden_mask) << 16) |
1066 181 (static_cast<uint64_t>(rule.trigger_mask) << 32);
1067 }
1068
1069 /// Unpacks a consume rule.
1070 228 constexpr GamepadConsumeRule unpack_consume_rule(uint64_t packed) noexcept
1071 {
1072 return GamepadConsumeRule{
1073 static_cast<uint16_t>(packed & 0xFFFFu),
1074 228 static_cast<uint16_t>((packed >> 16) & 0xFFFFu),
1075 228 static_cast<uint16_t>((packed >> 32) & 0xFFFFu)
1076 228 };
1077 }
1078
1079 std::array<std::atomic<std::uint64_t>, 4> s_wheel_count{
1080 wheel_count_slot(1, 0),
1081 wheel_count_slot(1, 0),
1082 wheel_count_slot(1, 0),
1083 wheel_count_slot(1, 0)
1084 };
1085 // Index zero stores vertical distance, and index one stores horizontal distance. Each axis has one signed
1086 // remainder because a reversal cancels sub-notch distance while the axes remain independent.
1087 std::array<std::atomic<std::uint64_t>, 2> s_wheel_remainder{
1088 wheel_remainder_slot(1, false, 0),
1089 wheel_remainder_slot(1, false, 0)
1090 };
1091 // The per-direction wheel-swallow mask uses WheelDirection bits and pairs with a TTL. Each poll cycle refreshes
1092 // it, so a stalled poll thread stops the swallow action. "Ctrl+WheelUp" must not eat bare WheelDown or WheelUp.
1093 std::atomic<uint8_t> s_wheel_consume_mask{0};
1094 std::atomic<uint64_t> s_wheel_consume_deadline_ms{0};
1095 // When set, wheel count admission and consume finalization also require process foreground. Published by the
1096 // poll loop together with the consume mask, and cleared on revocation.
1097 std::atomic<bool> s_wheel_require_focus{false};
1098
1099 /// Retags the wheel counters and remainders under @p wheel_epoch and clears the consume mask.
1100 188 void reset_wheel_data_plane(std::uint64_t wheel_epoch) noexcept
1101 {
1102 s_wheel_consume_mask.store(0, std::memory_order_release);
1103
2/2
✓ Branch 21 → 11 taken 752 times.
✓ Branch 21 → 22 taken 188 times.
940 for (auto &count : s_wheel_count)
1104 {
1105 752 count.store(wheel_count_slot(wheel_epoch, 0), std::memory_order_relaxed);
1106 }
1107
2/2
✓ Branch 33 → 23 taken 376 times.
✓ Branch 33 → 34 taken 188 times.
564 for (auto &remainder : s_wheel_remainder)
1108 {
1109 376 remainder.store(wheel_remainder_slot(wheel_epoch, false, 0), std::memory_order_relaxed);
1110 }
1111 188 }
1112
1113 182 void clear_data_plane_locked(std::uint64_t wheel_epoch) noexcept
1114 {
1115 // Single-atomic disarms occur first, so detour suppression stops before the multi-step rule update begins.
1116 s_suppress_mask.store(0, std::memory_order_release);
1117 182 s_rule_suppress_enabled.store(false, std::memory_order_relaxed);
1118 // A revoked layer carries no focus requirement, so the next owner starts from the default gate.
1119 182 s_wheel_require_focus.store(false, std::memory_order_relaxed);
1120 182 reset_wheel_data_plane(wheel_epoch);
1121
1122 // Safe only because every writer now holds this lock, so the bracket cannot interleave with a binding
1123 // mutation. The clear exists so a later owner cannot inherit the previous owner's chords.
1124 182 const uint32_t seq = s_consume_rules_seq.load(std::memory_order_relaxed);
1125 182 s_consume_rules_seq.store(seq + 1, std::memory_order_relaxed);
1126 std::atomic_thread_fence(std::memory_order_release);
1127 s_consume_rule_count.store(0, std::memory_order_relaxed);
1128 182 s_consume_rules_seq.store(seq + 2, std::memory_order_release);
1129 182 }
1130
1131 // Local message-hook wheel-capture source (the single-DLL default). It observes wheel messages through a
1132 // thread-scoped WH_GETMESSAGE hook and feeds the shared fold and drain machinery. The module reference is
1133 // permanent because a selected callback can run after UnhookWindowsHookEx.
1134 //
1135 // Route identity: the mounted target thread's numeric id, its SYNCHRONIZE handle, a mount generation, and a
1136 // typed route state. Readiness derives from the live handle and the mounted id, never from a sticky flag.
1137 // The handle, generation, and state transitions are guarded by s_intercept_mutex; the id and state atomics
1138 // give the hook callback and cheap queries a lock-free read.
1139 std::atomic<HHOOK> s_msg_hook{nullptr};
1140 std::atomic<bool> s_msg_hook_ref_taken{false};
1141 std::atomic<DWORD> s_msg_hook_thread_id{0};
1142 HANDLE s_msg_hook_thread = nullptr;
1143 std::uint64_t s_msg_hook_mount_generation = 0;
1144 std::atomic<std::uint8_t> s_msg_hook_route_state{static_cast<std::uint8_t>(WheelRouteState::TargetWait)};
1145
1146 // Counts callback frames inside a wheel admission phase (count admission or consume finalization). The
1147 // control plane drains it, bounded, after an epoch advance so no admitted decision reaches a successor
1148 // route. A frame parked inside a lower hook holds no phase.
1149 std::atomic<std::uint32_t> s_wheel_admitted_phases{0};
1150
1151 /// Bounds every control-plane wait for wheel admitted phases.
1152 constexpr std::uint64_t WHEEL_DRAIN_TIMEOUT_MS = 2000;
1153
1154 /// Takes a counted reference for the module at @p address and books it as an XInputTarget pin.
1155 160 [[nodiscard]] HMODULE acquire_module_ref_containing_address(const void *address) noexcept
1156 {
1157
1/2
✗ Branch 2 → 3 not taken.
✓ Branch 2 → 4 taken 160 times.
160 if (address == nullptr)
1158 {
1159 return nullptr;
1160 }
1161
1162 160 HMODULE module = nullptr;
1163
1/2
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 160 times.
160 if (!GetModuleHandleExW(
1164 GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS,
1165 reinterpret_cast<LPCWSTR>(address),
1166 &module
1167 ))
1168 {
1169 return nullptr;
1170 }
1171 160 module_pin_observability::note_acquired(diagnostics::ModulePinReason::XInputTarget);
1172 160 return module;
1173 }
1174
1175 /**
1176 * @brief Adds @p notches to an epoch-tagged, per-direction wheel counter with saturation.
1177 * @details A revocation retags the slot. A writer with the retired epoch fails and cannot publish into the
1178 * successor backlog. Saturation bounds idle accretion after the last binding is gone.
1179 * @return true when the slot records the notches or is already saturated. Returns false when @p epoch is
1180 * retired.
1181 */
1182 [[nodiscard]] bool
1183 1197 bump_wheel_notch(std::atomic<std::uint64_t> &slot, std::uint64_t epoch, std::uint64_t notches = 1) noexcept
1184 {
1185 1197 std::uint64_t current = slot.load(std::memory_order_relaxed);
1186
1/2
✓ Branch 22 → 10 taken 1197 times.
✗ Branch 22 → 23 not taken.
1197 while (wheel_slot_epoch(current) == epoch)
1187 {
1188 1197 const std::uint64_t count = current & WHEEL_COUNT_MASK;
1189
2/2
✓ Branch 10 → 11 taken 128 times.
✓ Branch 10 → 12 taken 1069 times.
1197 if (count >= MAX_WHEEL_NOTCHES)
1190 {
1191 128 return true;
1192 }
1193 const std::uint64_t next =
1194 1069 std::min<std::uint64_t>(count + notches, static_cast<std::uint64_t>(MAX_WHEEL_NOTCHES));
1195
1/2
✓ Branch 19 → 20 taken 1069 times.
✗ Branch 19 → 21 not taken.
2138 if (slot.compare_exchange_weak(
1196 current,
1197 wheel_count_slot(epoch, next),
1198 std::memory_order_relaxed,
1199 std::memory_order_relaxed
1200 ))
1201 {
1202 1069 return true;
1203 }
1204 }
1205 return false;
1206 }
1207
1208 /**
1209 * @brief Folds one wheel message's signed delta into an axis remainder tagged by (epoch, owned).
1210 * @details A stored tag from another (epoch, owned) state contributes nothing, so ownership flips and epoch
1211 * advances restart accumulation. A retired-epoch slot refuses the fold entirely.
1212 * @return The admission verdict and, when admitted, the signed whole-notch count of the fold.
1213 */
1214 struct WheelFold
1215 {
1216 bool admitted;
1217 int notches;
1218 };
1219 1210 [[nodiscard]] WheelFold accumulate_wheel_remainder(
1220 std::atomic<std::uint64_t> &slot,
1221 std::uint64_t epoch,
1222 bool owned,
1223 int delta
1224 ) noexcept
1225 {
1226 1210 std::uint64_t current = slot.load(std::memory_order_relaxed);
1227
1/2
✓ Branch 20 → 10 taken 1210 times.
✗ Branch 20 → 21 not taken.
1210 while ((current >> WHEEL_REMAINDER_EPOCH_SHIFT) == epoch)
1228 {
1229 1210 int prior = 0;
1230
2/2
✓ Branch 10 → 11 taken 1196 times.
✓ Branch 10 → 12 taken 14 times.
1210 if (((current & WHEEL_REMAINDER_OWNED_BIT) != 0) == owned)
1231 {
1232 1196 prior = static_cast<int>(current & WHEEL_REMAINDER_VALUE_MASK) - WHEEL_REMAINDER_BIAS;
1233 }
1234 // delta is a signed short and |prior| < WHEEL_DELTA, so the total cannot overflow int. The quotient
1235 // truncates toward zero and keeps the remainder sign equal to the total sign.
1236 1210 const int total = prior + delta;
1237 1210 const int notches = total / WHEEL_DELTA;
1238 1210 const int remainder = total % WHEEL_DELTA;
1239
1/2
✓ Branch 18 → 19 taken 1210 times.
✗ Branch 18 → 20 not taken.
2420 if (slot.compare_exchange_weak(
1240 current,
1241 wheel_remainder_slot(epoch, owned, remainder),
1242 std::memory_order_relaxed,
1243 std::memory_order_relaxed
1244 ))
1245 {
1246 1210 return WheelFold{true, notches};
1247 }
1248 }
1249 return WheelFold{false, 0};
1250 }
1251
1252 /**
1253 * @brief Reports whether the detour swallows a wheel message of the given direction this instant.
1254 * @details The acquire load of the mask orders the relaxed deadline read (publish_wheel_consume writes the
1255 * deadline first). A lapsed deadline forwards, so the game is never latched out of its wheel.
1256 * @param direction_bit One WheelDirection bit for the message direction.
1257 */
1258 1224 bool wheel_direction_consumed(uint8_t direction_bit) noexcept
1259 {
1260
2/2
✓ Branch 9 → 10 taken 1195 times.
✓ Branch 9 → 11 taken 29 times.
1224 if ((s_wheel_consume_mask.load(std::memory_order_acquire) & direction_bit) == 0)
1261 {
1262 1195 return false;
1263 }
1264 58 return GetTickCount64() < s_wheel_consume_deadline_ms.load(std::memory_order_relaxed);
1265 }
1266
1267 /// Brackets one wheel admission phase. Allocation-free, nonblocking, and free of DMK locks.
1268 class WheelPhaseGuard
1269 {
1270 public:
1271 1228 WheelPhaseGuard() noexcept { s_wheel_admitted_phases.fetch_add(1, std::memory_order_seq_cst); }
1272 1228 ~WheelPhaseGuard() noexcept { s_wheel_admitted_phases.fetch_sub(1, std::memory_order_seq_cst); }
1273 WheelPhaseGuard(const WheelPhaseGuard &) = delete;
1274 WheelPhaseGuard &operator=(const WheelPhaseGuard &) = delete;
1275 };
1276
1277 /// Reports whether this process owns the foreground window.
1278 5 [[nodiscard]] bool process_owns_foreground() noexcept
1279 {
1280 #if defined(DMK_ENABLE_TEST_SEAMS)
1281
1/2
✓ Branch 9 → 10 taken 5 times.
✗ Branch 9 → 11 not taken.
5 if (const std::int32_t override_value = s_wheel_process_focus_override.load(std::memory_order_acquire);
1282 override_value >= 0)
1283 {
1284 5 return override_value != 0;
1285 }
1286 #endif
1287 const HWND foreground = GetForegroundWindow();
1288 if (foreground == nullptr)
1289 {
1290 return false;
1291 }
1292 DWORD pid = 0;
1293 GetWindowThreadProcessId(foreground, &pid);
1294 return pid == GetCurrentProcessId();
1295 }
1296
1297 /// Reports whether the published focus gate admits wheel counting and consume right now.
1298 1226 [[nodiscard]] bool wheel_focus_admits() noexcept
1299 {
1300
4/4
✓ Branch 3 → 4 taken 5 times.
✓ Branch 3 → 6 taken 1221 times.
✓ Branch 5 → 6 taken 3 times.
✓ Branch 5 → 7 taken 2 times.
1226 return !s_wheel_require_focus.load(std::memory_order_relaxed) || process_owns_foreground();
1301 }
1302
1303 /**
1304 * @brief Waits, bounded, for every wheel admitted phase to leave.
1305 * @details Runs only on the control plane, after an epoch advance already invalidated the admissions it
1306 * waits on. A parked lower-hook frame holds no phase, so the wait covers only the short
1307 * allocation-free admission windows.
1308 */
1309 27 [[nodiscard]] bool drain_wheel_admitted_phases() noexcept
1310 {
1311 27 std::uint64_t timeout_ms = WHEEL_DRAIN_TIMEOUT_MS;
1312 #if defined(DMK_ENABLE_TEST_SEAMS)
1313
2/2
✓ Branch 9 → 10 taken 1 time.
✓ Branch 9 → 11 taken 26 times.
27 if (const std::uint64_t override_ms = s_wheel_drain_timeout_override_ms.load(std::memory_order_acquire);
1314 override_ms != 0)
1315 {
1316 1 timeout_ms = override_ms;
1317 }
1318 #endif
1319 27 const std::uint64_t deadline_ms = GetTickCount64() + timeout_ms;
1320
2/2
✓ Branch 24 → 13 taken 11837 times.
✓ Branch 24 → 25 taken 27 times.
11891 while (s_wheel_admitted_phases.load(std::memory_order_seq_cst) != 0)
1321 {
1322
1/2
✗ Branch 14 → 15 not taken.
✓ Branch 14 → 16 taken 11837 times.
11837 if (GetTickCount64() >= deadline_ms)
1323 {
1324 return false;
1325 }
1326 11837 Sleep(0);
1327 }
1328 27 return true;
1329 }
1330
1331 /// Returns the WheelDirection bit for one signed delta on one axis.
1332 1224 [[nodiscard]] uint8_t wheel_delta_direction_bit(bool horizontal, int delta) noexcept
1333 {
1334 // A positive vertical delta scrolls up, and a negative delta scrolls down. A positive horizontal delta
1335 // tilts right, and a negative delta tilts left.
1336
4/4
✓ Branch 2 → 3 taken 8 times.
✓ Branch 2 → 6 taken 1216 times.
✓ Branch 3 → 4 taken 2 times.
✓ Branch 3 → 5 taken 6 times.
2440 const WheelDirection direction = horizontal ? (delta > 0 ? WheelDirection::Right : WheelDirection::Left)
1337
2/2
✓ Branch 6 → 7 taken 1208 times.
✓ Branch 6 → 8 taken 8 times.
1216 : (delta > 0 ? WheelDirection::Up : WheelDirection::Down);
1338 1224 return wheel_direction_bit(direction);
1339 }
1340
1341 /**
1342 * @brief Wheel count admission: accumulates sub-notch distance, publishes whole notches, and snapshots the
1343 * consume intent, all before CallNextHookEx and without a message mutation.
1344 * @details GET_WHEEL_DELTA_WPARAM is signed and need not be a WHEEL_DELTA multiple. The axis remainder folds
1345 * fragments into `abs(total) / WHEEL_DELTA` notches. A reversal cancels accumulated distance. The
1346 * (epoch, owned) tag separates owned and unowned fragments (WheelDeltaTest.*). The admitted phase
1347 * is counted so the control plane can drain it.
1348 * @param horizontal When true, selects Right/Left. When false, selects Up/Down.
1349 * @param delta Signed wheel delta from the message.
1350 * @param capture_state Atomic capture state sampled when the callback frame began.
1351 * @return true when the frame snapshots consume intent for this message's direction.
1352 */
1353 1213 [[nodiscard]] bool wheel_count_admission(bool horizontal, int delta, std::uint64_t capture_state) noexcept
1354 {
1355
2/4
✓ Branch 2 → 3 taken 1213 times.
✗ Branch 2 → 4 not taken.
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 1213 times.
1213 if (delta == 0 || (capture_state & WHEEL_CAPTURE_ENABLED) == 0)
1356 {
1357 return false;
1358 }
1359 1213 const WheelPhaseGuard phase;
1360 #if defined(DMK_ENABLE_TEST_SEAMS)
1361
2/2
✓ Branch 7 → 8 taken 2 times.
✓ Branch 7 → 9 taken 1211 times.
1213 if (const WheelCaptureEntrySeam seam = s_wheel_capture_entry_seam.load(std::memory_order_acquire);
1362 seam != nullptr)
1363 {
1364 2 seam();
1365 }
1366 #endif
1367 // Re-read the capture state inside the counted phase, so an epoch advanced by a concurrent control
1368 // transaction refuses this admission.
1369 1213 const std::uint64_t epoch = wheel_capture_epoch(capture_state);
1370 1213 const std::uint64_t recheck = s_wheel_capture_state.load(std::memory_order_seq_cst);
1371
5/6
✓ Branch 18 → 19 taken 1211 times.
✓ Branch 18 → 22 taken 2 times.
✓ Branch 19 → 20 taken 1211 times.
✗ Branch 19 → 22 not taken.
✓ Branch 24 → 25 taken 3 times.
✓ Branch 24 → 26 taken 1210 times.
2424 if (wheel_capture_epoch(recheck) != epoch || (recheck & WHEEL_CAPTURE_ENABLED) == 0 ||
1372
2/2
✓ Branch 21 → 22 taken 1 time.
✓ Branch 21 → 23 taken 1210 times.
1211 !wheel_focus_admits())
1373 {
1374 3 return false;
1375 }
1376 1210 const bool owned = wheel_direction_consumed(wheel_delta_direction_bit(horizontal, delta));
1377 const WheelFold fold =
1378
2/2
✓ Branch 28 → 29 taken 8 times.
✓ Branch 28 → 30 taken 1202 times.
1210 accumulate_wheel_remainder(s_wheel_remainder[horizontal ? 1 : 0], epoch, owned, delta);
1379
1/2
✗ Branch 33 → 34 not taken.
✓ Branch 33 → 35 taken 1210 times.
1210 if (!fold.admitted)
1380 {
1381 return false;
1382 }
1383
2/2
✓ Branch 35 → 36 taken 1186 times.
✓ Branch 35 → 41 taken 24 times.
1210 if (fold.notches > 0)
1384 {
1385
2/2
✓ Branch 36 → 37 taken 2 times.
✓ Branch 36 → 38 taken 1184 times.
1186 const std::size_t positive_dir = horizontal ? 3u : 0u;
1386 1186 (void)bump_wheel_notch(s_wheel_count[positive_dir], epoch, static_cast<std::uint64_t>(fold.notches));
1387 }
1388
2/2
✓ Branch 41 → 42 taken 11 times.
✓ Branch 41 → 47 taken 13 times.
24 else if (fold.notches < 0)
1389 {
1390
2/2
✓ Branch 42 → 43 taken 5 times.
✓ Branch 42 → 44 taken 6 times.
11 const std::size_t negative_dir = horizontal ? 2u : 1u;
1391 11 (void)bump_wheel_notch(s_wheel_count[negative_dir], epoch, static_cast<std::uint64_t>(-fold.notches));
1392 }
1393 1210 return owned;
1394 1213 }
1395
1396 /**
1397 * @brief Wheel consume finalization: revalidates a saved consume intent after CallNextHookEx returned.
1398 * @details The WM_NULL write is permitted only while the entry epoch, the consume mask, its TTL, and the
1399 * focus gate all remain current. The admitted phase is counted so the control plane can drain it.
1400 * @return true when the caller must rewrite the message to WM_NULL.
1401 */
1402 15 [[nodiscard]] bool wheel_consume_finalization(bool horizontal, int delta, std::uint64_t capture_state) noexcept
1403 {
1404 15 const WheelPhaseGuard phase;
1405 #if defined(DMK_ENABLE_TEST_SEAMS)
1406
2/2
✓ Branch 4 → 5 taken 1 time.
✓ Branch 4 → 6 taken 14 times.
15 if (const WheelFinalizeEntrySeam seam = s_wheel_finalize_entry_seam.load(std::memory_order_acquire);
1407 seam != nullptr)
1408 {
1409 1 seam();
1410 }
1411 #endif
1412 15 const std::uint64_t epoch = wheel_capture_epoch(capture_state);
1413 15 const std::uint64_t recheck = s_wheel_capture_state.load(std::memory_order_seq_cst);
1414
4/6
✓ Branch 15 → 16 taken 15 times.
✗ Branch 15 → 19 not taken.
✓ Branch 16 → 17 taken 15 times.
✗ Branch 16 → 19 not taken.
✓ Branch 21 → 22 taken 1 time.
✓ Branch 21 → 23 taken 14 times.
30 if (wheel_capture_epoch(recheck) != epoch || (recheck & WHEEL_CAPTURE_ENABLED) == 0 ||
1415
2/2
✓ Branch 18 → 19 taken 1 time.
✓ Branch 18 → 20 taken 14 times.
15 !wheel_focus_admits())
1416 {
1417 1 return false;
1418 }
1419 14 return wheel_direction_consumed(wheel_delta_direction_bit(horizontal, delta));
1420 15 }
1421
1422 /**
1423 * @brief Clears the suppressed button bits from a game-bound XINPUT_STATE.
1424 * @details dwPacketNumber and the success return stay untouched, so the game sees a connected controller with
1425 * packet progress. The cleared bits combine the reactive mask with the consume rules. A TTL guard
1426 * drops all suppression after poll refreshes stop.
1427 */
1428 12 void apply_suppress(XINPUT_STATE *state, DWORD user_index) noexcept
1429 {
1430 // A retained primary route can remain physically reachable before recovery completes for its Ex partner.
1431 // Keep both routes fail-open until the complete logical installation is published.
1432
5/6
✓ Branch 3 → 4 taken 6 times.
✓ Branch 3 → 5 taken 6 times.
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 6 times.
✓ Branch 7 → 8 taken 6 times.
✓ Branch 7 → 9 taken 6 times.
12 if (!s_xinput_installed.load(std::memory_order_acquire) || state == nullptr)
1433 {
1434 6 return;
1435 }
1436
1/2
✗ Branch 16 → 17 not taken.
✓ Branch 16 → 18 taken 6 times.
6 if (static_cast<int>(user_index) != s_bound_user_index.load(std::memory_order_relaxed))
1437 {
1438 return;
1439 }
1440 // The acquire load of the mask orders the relaxed deadline read below (the writer stores the deadline
1441 // first), even when the mask reads as 0.
1442 6 const uint16_t reactive = s_suppress_mask.load(std::memory_order_acquire);
1443
1444 // raw is the true, unmasked state because this detour runs after the trampoline call. A chord pressed
1445 // within one poll interval masks on the frame when the game reads it. The focus gate suppresses rule
1446 // evaluation while unfocused or disconnected, because the rule list and deadline survive those
1447 // transitions.
1448 6 const uint16_t raw = state->Gamepad.wButtons;
1449 const uint16_t rule_mask =
1450
1/2
✗ Branch 26 → 27 not taken.
✓ Branch 26 → 28 taken 6 times.
6 s_rule_suppress_enabled.load(std::memory_order_relaxed) ? evaluate_published_consume_rules(raw) : 0;
1451 6 const uint16_t mask = static_cast<uint16_t>(reactive | rule_mask);
1452
1/2
✗ Branch 29 → 30 not taken.
✓ Branch 29 → 31 taken 6 times.
6 if (mask == 0)
1453 {
1454 return;
1455 }
1456 // A stalled poll thread lets the deadline lapse and stops all suppression. The game regains its input
1457 // instead of a permanent latch.
1458
1/2
✗ Branch 39 → 40 not taken.
✓ Branch 39 → 41 taken 6 times.
12 if (GetTickCount64() >= s_suppress_deadline_ms.load(std::memory_order_relaxed))
1459 {
1460 return;
1461 }
1462 6 state->Gamepad.wButtons = static_cast<WORD>(raw & static_cast<WORD>(~mask));
1463 }
1464
1465 7583 DWORD WINAPI xinput_get_state_detour(DWORD user_index, XINPUT_STATE *state) noexcept
1466 {
1467 7583 const InflightGuard inflight;
1468 #if defined(DMK_ENABLE_TEST_SEAMS)
1469
2/2
✓ Branch 4 → 5 taken 2 times.
✓ Branch 4 → 6 taken 7581 times.
7583 if (auto *seam = s_xinput_detour_body_seam.load(std::memory_order_acquire))
1470 {
1471 2 seam();
1472 }
1473 #endif
1474 // This seq_cst load forms the detour side of the Dekker drain pair. See InflightGuard.
1475 7583 const XInputGetStateFn original = s_xinput_original.load(std::memory_order_seq_cst);
1476
1/2
✓ Branch 7 → 8 taken 7583 times.
✗ Branch 7 → 10 not taken.
7583 const DWORD result = (original != nullptr) ? original(user_index, state) : ERROR_DEVICE_NOT_CONNECTED;
1477
1/2
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 13 taken 7583 times.
7583 if (result == ERROR_SUCCESS)
1478 {
1479 apply_suppress(state, user_index);
1480 }
1481 15166 return result;
1482 7583 }
1483
1484 8 DWORD WINAPI xinput_get_state_ex_detour(DWORD user_index, XINPUT_STATE *state) noexcept
1485 {
1486 8 const InflightGuard inflight;
1487 // This seq_cst load serves the same Dekker-pair reason as xinput_get_state_detour above.
1488 8 const XInputGetStateFn original = s_xinput_ex_original.load(std::memory_order_seq_cst);
1489
1/2
✓ Branch 4 → 5 taken 8 times.
✗ Branch 4 → 7 not taken.
8 const DWORD result = (original != nullptr) ? original(user_index, state) : ERROR_DEVICE_NOT_CONNECTED;
1490
1/2
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 8 times.
8 if (result == ERROR_SUCCESS)
1491 {
1492 apply_suppress(state, user_index);
1493 }
1494 16 return result;
1495 8 }
1496
1497 // Local wheel source. A thread-scoped WH_GETMESSAGE hook on the selected game UI thread. It folds and counts
1498 // on PM_REMOVE only. The Stage 0 spike (docs/analysis/wheel_hook_spike_v4) froze the retrieval semantics:
1499 // NOREMOVE observes nothing and retrieval is counted once. Order contract (4.1): count admission runs before
1500 // CallNextHookEx with no message mutation, so older hooks see the original record; CallNextHookEx runs
1501 // exactly once; consume finalization writes WM_NULL after it returns, only while every admission condition
1502 // still holds. A newer hook can rewrite the message after this returns; the consume stays best effort.
1503 1197 LRESULT CALLBACK message_hook_proc(int code, WPARAM wparam, LPARAM lparam) noexcept
1504 {
1505
1/2
✗ Branch 2 → 3 not taken.
✓ Branch 2 → 5 taken 1197 times.
1197 if (code != HC_ACTION)
1506 {
1507 return CallNextHookEx(nullptr, code, wparam, lparam);
1508 }
1509 1197 MSG *message = reinterpret_cast<MSG *>(lparam);
1510
1/2
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 8 taken 1197 times.
1197 if (message == nullptr)
1511 {
1512 return CallNextHookEx(nullptr, code, wparam, lparam);
1513 }
1514
4/4
✓ Branch 8 → 9 taken 11 times.
✓ Branch 8 → 10 taken 1186 times.
✓ Branch 9 → 10 taken 5 times.
✓ Branch 9 → 11 taken 6 times.
1197 const bool is_wheel = message->message == WM_MOUSEWHEEL || message->message == WM_MOUSEHWHEEL;
1515
4/4
✓ Branch 12 → 13 taken 1191 times.
✓ Branch 12 → 14 taken 6 times.
✓ Branch 13 → 14 taken 5 times.
✓ Branch 13 → 16 taken 1186 times.
1197 if (!is_wheel || wparam != PM_REMOVE)
1516 {
1517 11 return CallNextHookEx(nullptr, code, wparam, lparam);
1518 }
1519 // Reject a callback whose current thread does not match the published route. A reused numeric id cannot
1520 // revive retired state: the route id is republished only by a mount transaction.
1521
1/2
✗ Branch 24 → 25 not taken.
✓ Branch 24 → 27 taken 1186 times.
2372 if (GetCurrentThreadId() != s_msg_hook_thread_id.load(std::memory_order_acquire))
1522 {
1523 return CallNextHookEx(nullptr, code, wparam, lparam);
1524 }
1525 1186 const std::uint64_t capture_state = s_wheel_capture_state.load(std::memory_order_seq_cst);
1526 1186 const bool horizontal = message->message == WM_MOUSEHWHEEL;
1527 1186 const int delta = GET_WHEEL_DELTA_WPARAM(message->wParam);
1528 1186 const bool consume_intent = wheel_count_admission(horizontal, delta, capture_state);
1529 1186 const LRESULT result = CallNextHookEx(nullptr, code, wparam, lparam);
1530
6/6
✓ Branch 36 → 37 taken 11 times.
✓ Branch 36 → 40 taken 1175 times.
✓ Branch 38 → 39 taken 10 times.
✓ Branch 38 → 40 taken 1 time.
✓ Branch 41 → 42 taken 10 times.
✓ Branch 41 → 43 taken 1176 times.
1186 if (consume_intent && wheel_consume_finalization(horizontal, delta, capture_state))
1531 {
1532 10 message->message = WM_NULL;
1533 10 message->wParam = 0;
1534 10 message->lParam = 0;
1535 }
1536 1186 return result;
1537 }
1538
1539 /// Requires s_intercept_mutex. Reports whether the mounted route's target thread exited.
1540 74 [[nodiscard]] bool msg_hook_target_thread_exited_locked() noexcept
1541 {
1542
3/4
✓ Branch 2 → 3 taken 74 times.
✗ Branch 2 → 6 not taken.
✓ Branch 4 → 5 taken 4 times.
✓ Branch 4 → 6 taken 70 times.
74 return s_msg_hook_thread != nullptr && WaitForSingleObject(s_msg_hook_thread, 0) == WAIT_OBJECT_0;
1543 }
1544
1545 /**
1546 * @brief Removes an OS hook, tolerating a handle the OS already reclaimed.
1547 * @details The poll thread installs the wheel hook, so the OS retires the thread-owned hook when that thread
1548 * exits. A later removal on the control thread then fails with an invalid-handle error, which is a
1549 * successful removal, not a live-thread cleanup failure. A genuine failure keeps the caller blocked.
1550 * @return true when the hook is gone (removed now, or already reclaimed).
1551 */
1552 27 [[nodiscard]] bool unhook_or_already_gone(HHOOK hook) noexcept
1553 {
1554 #if defined(DMK_ENABLE_TEST_SEAMS)
1555
2/2
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 26 times.
27 if (s_force_message_unhook_failure.load(std::memory_order_acquire))
1556 {
1557 1 SetLastError(0);
1558 }
1559 else
1560 #endif
1561 {
1562 26 SetLastError(0);
1563
2/2
✓ Branch 7 → 8 taken 18 times.
✓ Branch 7 → 9 taken 8 times.
26 if (UnhookWindowsHookEx(hook) != 0)
1564 {
1565 18 return true;
1566 }
1567 }
1568 9 const DWORD error = GetLastError();
1569 9 return error == ERROR_INVALID_HOOK_HANDLE;
1570 }
1571
1572 /**
1573 * @brief Requires s_intercept_mutex. Drops the mounted route after its target thread exited.
1574 * @details Thread exit is authoritative hook retirement: the OS hook died with its thread, so the unhook
1575 * call only releases the handle and its result carries no authority.
1576 */
1577 4 void retire_message_hook_route_locked() noexcept
1578 {
1579
1/2
✓ Branch 3 → 4 taken 4 times.
✗ Branch 3 → 6 not taken.
4 if (const HHOOK hook = s_msg_hook.load(std::memory_order_relaxed); hook != nullptr)
1580 {
1581 4 (void)UnhookWindowsHookEx(hook);
1582 4 s_msg_hook.store(nullptr, std::memory_order_release);
1583 }
1584 s_msg_hook_thread_id.store(0, std::memory_order_release);
1585
1/2
✓ Branch 14 → 15 taken 4 times.
✗ Branch 14 → 17 not taken.
4 if (s_msg_hook_thread != nullptr)
1586 {
1587 4 CloseHandle(s_msg_hook_thread);
1588 4 s_msg_hook_thread = nullptr;
1589 }
1590 4 const std::uint64_t wheel_epoch = close_wheel_capture_and_advance_epoch();
1591 {
1592 4 const DataPlaneLockGuard data_lock;
1593 4 reset_wheel_data_plane(wheel_epoch);
1594 4 }
1595 s_msg_hook_route_state.store(
1596 static_cast<std::uint8_t>(WheelRouteState::Retryable),
1597 std::memory_order_release
1598 );
1599 4 }
1600
1601 /**
1602 * @brief Requires s_intercept_mutex. Rechecks target liveness and settles the route state.
1603 * @details A dead target cannot remain ready, and a cleanup-blocked route whose old thread exited becomes
1604 * retryable.
1605 */
1606 217 void settle_message_hook_route_locked() noexcept
1607 {
1608
6/6
✓ Branch 3 → 4 taken 47 times.
✓ Branch 3 → 7 taken 170 times.
✓ Branch 5 → 6 taken 4 times.
✓ Branch 5 → 7 taken 43 times.
✓ Branch 8 → 9 taken 4 times.
✓ Branch 8 → 10 taken 213 times.
217 if (s_msg_hook.load(std::memory_order_relaxed) != nullptr && msg_hook_target_thread_exited_locked())
1609 {
1610 4 retire_message_hook_route_locked();
1611 }
1612 217 }
1613
1614 153 void uninstall_message_hook() noexcept
1615 {
1616 153 const HHOOK hook = s_msg_hook.load(std::memory_order_acquire);
1617
2/2
✓ Branch 3 → 4 taken 128 times.
✓ Branch 3 → 5 taken 25 times.
153 if (hook == nullptr)
1618 {
1619 128 return;
1620 }
1621 // The caller's revocation already advanced the epoch, so admitted decisions are stale; the bounded
1622 // drain only waits out the short admission windows. The phase atomics are process-lifetime statics, so
1623 // a drain timeout is a correctness residual, not a memory hazard, and teardown still proceeds.
1624 25 (void)drain_wheel_admitted_phases();
1625
1/2
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 25 times.
25 if (msg_hook_target_thread_exited_locked())
1626 {
1627 retire_message_hook_route_locked();
1628 }
1629
2/2
✓ Branch 10 → 11 taken 1 time.
✓ Branch 10 → 20 taken 24 times.
25 else if (!unhook_or_already_gone(hook))
1630 {
1631 // Cleanup only. The permanent module reference is retained either way, because Microsoft permits a
1632 // selected callback to run after UnhookWindowsHookEx returns.
1633 s_msg_hook_route_state.store(
1634 static_cast<std::uint8_t>(WheelRouteState::CleanupBlocked),
1635 std::memory_order_release
1636 );
1637 1 return;
1638 }
1639 else
1640 {
1641 24 s_msg_hook.store(nullptr, std::memory_order_release);
1642 s_msg_hook_thread_id.store(0, std::memory_order_release);
1643
1/2
✓ Branch 29 → 30 taken 24 times.
✗ Branch 29 → 32 not taken.
24 if (s_msg_hook_thread != nullptr)
1644 {
1645 24 CloseHandle(s_msg_hook_thread);
1646 24 s_msg_hook_thread = nullptr;
1647 }
1648 s_msg_hook_route_state.store(
1649 static_cast<std::uint8_t>(WheelRouteState::TargetWait),
1650 std::memory_order_release
1651 );
1652 }
1653 // The caller's revocation already advanced the capture epoch and reset the data plane at that epoch. A
1654 // second advance here would leave the count and remainder slots tagged with a stale epoch, so the next
1655 // owner's mount would fold nothing. Do not advance again.
1656 }
1657 } // anonymous namespace
1658
1659 89 uint8_t step_wheel_pulse(WheelPulseState &state) noexcept
1660 {
1661 89 uint8_t mask = 0;
1662
2/2
✓ Branch 13 → 3 taken 356 times.
✓ Branch 13 → 14 taken 89 times.
445 for (int dir = 0; dir < 4; ++dir)
1663 {
1664
2/2
✓ Branch 4 → 5 taken 23 times.
✓ Branch 4 → 7 taken 333 times.
356 if (state.pulsing[dir])
1665 {
1666 // Force one low cycle after a pulse so the edge detector re-arms.
1667 23 state.pulsing[dir] = false;
1668 }
1669
2/2
✓ Branch 8 → 9 taken 26 times.
✓ Branch 8 → 12 taken 307 times.
333 else if (state.pending[dir] > 0)
1670 {
1671 26 --state.pending[dir];
1672 26 mask = static_cast<uint8_t>(mask | (1u << dir));
1673 26 state.pulsing[dir] = true;
1674 }
1675 }
1676 89 return mask;
1677 }
1678
1679 150 void add_wheel_notches(WheelPulseState &state, const std::array<int, 4> &taken) noexcept
1680 {
1681
2/2
✓ Branch 15 → 3 taken 600 times.
✓ Branch 15 → 16 taken 150 times.
750 for (size_t dir = 0; dir < 4; ++dir)
1682 {
1683
2/2
✓ Branch 4 → 5 taken 408 times.
✓ Branch 4 → 7 taken 192 times.
600 const int add = taken[dir] > 0 ? taken[dir] : 0;
1684 // pending is in [0, MAX_WHEEL_PENDING] by induction, so room is nonnegative. Compare against room before
1685 // the addition so a large burst saturates instead of an int overflow.
1686 600 const int room = MAX_WHEEL_PENDING - state.pending[dir];
1687
2/2
✓ Branch 9 → 10 taken 198 times.
✓ Branch 9 → 12 taken 402 times.
600 state.pending[dir] = (add >= room) ? MAX_WHEEL_PENDING : state.pending[dir] + add;
1688 }
1689 150 }
1690
1691 18 uint16_t step_gamepad_suppress(
1692 GamepadSuppressState &state,
1693 uint16_t owned_now,
1694 uint16_t true_buttons,
1695 uint64_t now_ms,
1696 uint64_t grace_ms
1697 ) noexcept
1698 {
1699 // Sentinel deadline denotes "actively held, with no release underway."
1700 18 constexpr uint64_t held_sentinel = UINT64_MAX;
1701
1702 18 uint16_t mask = 0;
1703 18 const uint16_t relevant = static_cast<uint16_t>(state.armed | owned_now);
1704
2/2
✓ Branch 21 → 3 taken 288 times.
✓ Branch 21 → 22 taken 18 times.
306 for (int bit = 0; bit < 16; ++bit)
1705 {
1706 288 const uint16_t bit_mask = static_cast<uint16_t>(1u << bit);
1707
2/2
✓ Branch 3 → 4 taken 272 times.
✓ Branch 3 → 5 taken 16 times.
288 if ((relevant & bit_mask) == 0)
1708 {
1709 272 continue;
1710 }
1711 16 const bool phys_down = (true_buttons & bit_mask) != 0;
1712 16 const bool owned = (owned_now & bit_mask) != 0;
1713
1714
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))
1715 {
1716 // A current chord or a still-held trigger keeps suppression active after modifier release. Cancel any
1717 // active release grace.
1718 9 state.armed = static_cast<uint16_t>(state.armed | bit_mask);
1719 9 state.deadline_ms[static_cast<size_t>(bit)] = held_sentinel;
1720 9 mask = static_cast<uint16_t>(mask | bit_mask);
1721 }
1722
1/2
✓ Branch 10 → 11 taken 7 times.
✗ Branch 10 → 20 not taken.
7 else if ((state.armed & bit_mask) != 0)
1723 {
1724 // If the armed button is physically up, run the release grace so a final bare-trigger frame cannot
1725 // reach the game.
1726
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)
1727 {
1728 4 state.deadline_ms[static_cast<size_t>(bit)] = now_ms + grace_ms;
1729 }
1730
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)])
1731 {
1732 4 mask = static_cast<uint16_t>(mask | bit_mask);
1733 }
1734 else
1735 {
1736 3 state.armed = static_cast<uint16_t>(state.armed & static_cast<uint16_t>(~bit_mask));
1737 3 state.deadline_ms[static_cast<size_t>(bit)] = 0;
1738 }
1739 }
1740 }
1741 18 return mask;
1742 }
1743
1744 60 uint16_t evaluate_consume_rules(uint16_t true_buttons, const GamepadConsumeRule *rules, std::size_t count) noexcept
1745 {
1746 60 uint16_t mask = 0;
1747
2/2
✓ Branch 7 → 3 taken 251 times.
✓ Branch 7 → 8 taken 60 times.
311 for (std::size_t i = 0; i < count; ++i)
1748 {
1749 251 const GamepadConsumeRule &rule = rules[i];
1750 // The rule requires every modifier bit and rejects every forbidden bit. This matches the poll loop's exact
1751 // decision against the snapshot that the game reads. A forbidden bit belongs to a different chord.
1752
4/4
✓ Branch 3 → 4 taken 109 times.
✓ Branch 3 → 6 taken 142 times.
✓ Branch 4 → 5 taken 101 times.
✓ Branch 4 → 6 taken 8 times.
251 if ((true_buttons & rule.modifier_mask) == rule.modifier_mask && (true_buttons & rule.forbidden_mask) == 0)
1753 {
1754 101 mask = static_cast<uint16_t>(mask | rule.trigger_mask);
1755 }
1756 }
1757 60 return mask;
1758 }
1759
1760 ConsumePublish
1761 3642 publish_gamepad_consume_rules(const GamepadConsumeRule *rules, std::size_t count, std::uint64_t owner) noexcept
1762 {
1763 3642 run_data_plane_entry_seam();
1764 3642 const DataPlaneLockGuard data_lock;
1765
2/2
✓ Branch 5 → 6 taken 3594 times.
✓ Branch 5 → 7 taken 48 times.
3642 if (!data_plane_authorized(owner))
1766 {
1767 // Refuse before the seqlock bracket opens, so an unauthorized caller leaves the sequence untouched and
1768 // even.
1769 3594 return {};
1770 }
1771
1772 // Keep the rules that fit and drop the rest. Each retained rule protects its chord. An empty list revokes all
1773 // initial-edge protection because one rule did not fit.
1774
2/2
✓ Branch 7 → 8 taken 43 times.
✓ Branch 7 → 9 taken 5 times.
48 const std::size_t published = count < MAX_GAMEPAD_CONSUME_RULES ? count : MAX_GAMEPAD_CONSUME_RULES;
1775 // This writer brackets the update with an odd sequence. The release fence keeps rule stores inside that
1776 // bracket. The even release store publishes the final list.
1777 48 const uint32_t seq = s_consume_rules_seq.load(std::memory_order_relaxed);
1778 48 s_consume_rules_seq.store(seq + 1, std::memory_order_relaxed);
1779 std::atomic_thread_fence(std::memory_order_release);
1780
2/2
✓ Branch 38 → 27 taken 181 times.
✓ Branch 38 → 39 taken 48 times.
229 for (std::size_t i = 0; i < published; ++i)
1781 {
1782 181 s_consume_rules[i].store(pack_consume_rule(rules[i]), std::memory_order_relaxed);
1783 }
1784 48 s_consume_rule_count.store(static_cast<uint32_t>(published), std::memory_order_relaxed);
1785 48 s_consume_rules_seq.store(seq + 2, std::memory_order_release);
1786 48 return {true, published};
1787 3642 }
1788
1789 44 uint16_t evaluate_published_consume_rules(uint16_t true_buttons) noexcept
1790 {
1791 // Seqlock read with one attempt and no spin. On an odd sequence or torn snapshot, skip rule suppression for
1792 // this frame. The reactive mask still applies. The next game poll gets the settled list.
1793 44 const uint32_t seq_before = s_consume_rules_seq.load(std::memory_order_acquire);
1794
1/2
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 44 times.
44 if ((seq_before & 1u) != 0)
1795 {
1796 return 0;
1797 }
1798 44 uint32_t count = s_consume_rule_count.load(std::memory_order_relaxed);
1799
1/2
✗ Branch 18 → 19 not taken.
✓ Branch 18 → 20 taken 44 times.
44 if (count > MAX_GAMEPAD_CONSUME_RULES)
1800 {
1801 count = MAX_GAMEPAD_CONSUME_RULES;
1802 }
1803 44 std::array<GamepadConsumeRule, MAX_GAMEPAD_CONSUME_RULES> snapshot{};
1804
2/2
✓ Branch 32 → 21 taken 228 times.
✓ Branch 32 → 33 taken 44 times.
272 for (uint32_t i = 0; i < count; ++i)
1805 {
1806 456 snapshot[i] = unpack_consume_rule(s_consume_rules[i].load(std::memory_order_relaxed));
1807 }
1808 // Order the rule loads before the sequence re-read, so a mid-copy writer is always detected.
1809 std::atomic_thread_fence(std::memory_order_acquire);
1810
1/2
✗ Branch 41 → 42 not taken.
✓ Branch 41 → 43 taken 44 times.
44 if (s_consume_rules_seq.load(std::memory_order_relaxed) != seq_before)
1811 {
1812 return 0;
1813 }
1814 88 return evaluate_consume_rules(true_buttons, snapshot.data(), count);
1815 }
1816
1817 2 bool set_gamepad_rule_suppress_enabled(bool enabled, std::uint64_t owner) noexcept
1818 {
1819 2 run_data_plane_entry_seam();
1820 2 const DataPlaneLockGuard data_lock;
1821
2/2
✓ Branch 5 → 6 taken 1 time.
✓ Branch 5 → 7 taken 1 time.
2 if (!data_plane_authorized(owner))
1822 {
1823 1 return false;
1824 }
1825 1 s_rule_suppress_enabled.store(enabled, std::memory_order_relaxed);
1826 1 return true;
1827 2 }
1828
1829 372 std::uint64_t next_intercept_owner() noexcept
1830 {
1831 for (;;)
1832 {
1833 372 const std::uint64_t owner = s_next_intercept_owner.fetch_add(1, std::memory_order_relaxed);
1834
2/4
✓ Branch 5 → 6 taken 372 times.
✗ Branch 5 → 8 not taken.
✓ Branch 6 → 7 taken 372 times.
✗ Branch 6 → 8 not taken.
372 if (owner != 0 && owner != STANDALONE_INTERCEPT_OWNER)
1835 {
1836 372 return owner;
1837 }
1838 }
1839 }
1840
1841 678 bool intercept_owned_by(std::uint64_t owner) noexcept
1842 {
1843
4/4
✓ Branch 2 → 3 taken 677 times.
✓ Branch 2 → 12 taken 1 time.
✓ Branch 10 → 11 taken 22 times.
✓ Branch 10 → 12 taken 655 times.
1355 return owner != 0 && s_intercept_owner.load(std::memory_order_acquire) == owner;
1844 }
1845
1846 #if defined(DMK_ENABLE_TEST_SEAMS)
1847 45 bool acquire_standalone_lease_for_test() noexcept
1848 {
1849 45 const InterceptLockGuard lock{s_intercept_mutex};
1850
1/2
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 45 times.
45 if (!owner_available(STANDALONE_INTERCEPT_OWNER))
1851 {
1852 return false;
1853 }
1854 45 publish_owner(STANDALONE_INTERCEPT_OWNER);
1855 45 return true;
1856 45 }
1857
1858 7 bool xinput_pair_degraded_for_test() noexcept
1859 {
1860 7 return s_xinput_pair_degraded.load(std::memory_order_acquire);
1861 }
1862
1863 11 XInputPairCoverage xinput_pair_coverage_for_test() noexcept
1864 {
1865 11 const InterceptLockGuard lock{s_intercept_mutex};
1866
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 11 times.
11 if (s_xinput_permanent_hooks == nullptr)
1867 {
1868 return XInputPairCoverage{false, true};
1869 }
1870 return XInputPairCoverage{
1871 11 xinput_member_entry_witnessed(s_xinput_permanent_hooks->primary, true),
1872 11 xinput_member_entry_witnessed(s_xinput_permanent_hooks->ex, static_cast<bool>(s_xinput_permanent_hooks->ex))
1873 22 };
1874 11 }
1875
1876 9 std::size_t xinput_recovery_attempts_for_test() noexcept
1877 {
1878 9 return s_xinput_recovery_attempts.load(std::memory_order_relaxed);
1879 }
1880
1881 10 std::uint64_t expire_xinput_recovery_delay_for_test() noexcept
1882 {
1883 10 const InterceptLockGuard lock{s_intercept_mutex};
1884 10 const std::uint64_t delay_ms = s_xinput_recovery_delay_ms;
1885 10 s_xinput_recovery_not_before_ms = 0;
1886 20 return delay_ms;
1887 10 }
1888
1889 44 bool adopt_owner_for_test(std::uint64_t owner) noexcept
1890 {
1891 44 const InterceptLockGuard lock{s_intercept_mutex};
1892
1/2
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 44 times.
44 if (!owner_available(owner))
1893 {
1894 return false;
1895 }
1896 44 publish_owner(owner);
1897 44 return true;
1898 44 }
1899 45 void release_standalone_lease_for_test() noexcept
1900 {
1901 45 const InterceptLockGuard lock{s_intercept_mutex};
1902
2/2
✓ Branch 10 → 11 taken 16 times.
✓ Branch 10 → 12 taken 29 times.
45 if (s_intercept_owner.load(std::memory_order_relaxed) != STANDALONE_INTERCEPT_OWNER)
1903 {
1904 16 return;
1905 }
1906 29 revoke_owner_and_clear_data();
1907
2/2
✓ Branch 15 → 16 taken 29 times.
✓ Branch 15 → 18 taken 16 times.
45 }
1908 #endif
1909
1910 276 bool install_xinput(int user_index, std::uint64_t owner) noexcept
1911 {
1912 276 InterceptLockGuard lock{s_intercept_mutex};
1913 276 XInputRetentionLog deferred_log;
1914
1915
2/2
✓ Branch 4 → 5 taken 34 times.
✓ Branch 4 → 6 taken 242 times.
276 if (!owner_available(owner))
1916 {
1917 34 return false;
1918 }
1919
1920
2/2
✓ Branch 7 → 8 taken 74 times.
✓ Branch 7 → 17 taken 168 times.
242 if (s_xinput_permanent_detour.load(std::memory_order_acquire))
1921 {
1922 74 PermanentXInputHooks *const permanent = permanent_cell();
1923
1/2
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 12 taken 74 times.
74 if (!permanent->primary)
1924 {
1925 // No primary storage remains. A fresh hook over the current export creates the uncertain-storage case
1926 // that retention avoids.
1927 return false;
1928 }
1929 // Recovery re-arms each absent member through its retained hook. It never creates a second hook over the
1930 // current prologue.
1931 74 const bool whole = maintain_xinput_pair(
1932 74 permanent->primary,
1933 74 permanent->ex,
1934
1/2
✗ Branch 12 → 13 not taken.
✓ Branch 12 → 14 taken 74 times.
74 permanent->ex_target_ref != nullptr ? permanent->ex_target_ref : permanent->target_ref,
1935 user_index,
1936 owner
1937 );
1938 74 return whole;
1939 }
1940
1941 // A live pair uses one transaction for health maintenance and recovery. A second hook over its prologue
1942 // captures the first hook's jmp as its original and corrupts the trampoline chain.
1943
6/6
✓ Branch 17 → 18 taken 131 times.
✓ Branch 17 → 21 taken 37 times.
✓ Branch 19 → 20 taken 86 times.
✓ Branch 19 → 21 taken 45 times.
✓ Branch 22 → 23 taken 86 times.
✓ Branch 22 → 28 taken 82 times.
168 if (s_xinput_permanent_hooks != nullptr && static_cast<bool>(s_xinput_permanent_hooks->primary))
1944 {
1945 258 return maintain_xinput_pair(
1946 86 s_xinput_permanent_hooks->primary,
1947 86 s_xinput_permanent_hooks->ex,
1948
1/2
✗ Branch 23 → 24 not taken.
✓ Branch 23 → 25 taken 86 times.
86 s_xinput_permanent_hooks->ex_target_ref != nullptr ? s_xinput_permanent_hooks->ex_target_ref
1949 86 : s_xinput_permanent_hooks->target_ref,
1950 user_index,
1951 owner
1952 86 );
1953 }
1954
1955 82 HMODULE module = nullptr;
1956 #if defined(DMK_ENABLE_TEST_SEAMS)
1957
2/2
✓ Branch 28 → 29 taken 27 times.
✓ Branch 28 → 30 taken 55 times.
82 if (s_xinput_module_override != nullptr)
1958 {
1959 27 module = s_xinput_module_override;
1960 }
1961 else
1962 #endif
1963 {
1964
1/2
✓ Branch 35 → 31 taken 55 times.
✗ Branch 35 → 36 not taken.
55 for (const wchar_t *name : XINPUT_DLL_NAMES)
1965 {
1966 55 module = GetModuleHandleW(name);
1967
1/2
✓ Branch 32 → 33 taken 55 times.
✗ Branch 32 → 34 not taken.
55 if (module != nullptr)
1968 {
1969 55 break;
1970 }
1971 }
1972 }
1973
1/2
✗ Branch 36 → 37 not taken.
✓ Branch 36 → 38 taken 82 times.
82 if (module == nullptr)
1974 {
1975 return false; // XInput is not loaded yet. The poll loop retries.
1976 }
1977
1978 82 auto *get_state = reinterpret_cast<void *>(GetProcAddress(module, "XInputGetState"));
1979
2/2
✓ Branch 39 → 40 taken 1 time.
✓ Branch 39 → 41 taken 81 times.
82 if (get_state == nullptr)
1980 {
1981 1 return false;
1982 }
1983
1984 // XInputGetStateEx (ordinal 100) carries the Guide button. Without its hook, a game can bypass the mask.
1985 // An absent export needs no second hook. The primary route already covers an alias.
1986 auto *get_state_ex =
1987 81 reinterpret_cast<void *>(GetProcAddress(module, MAKEINTRESOURCEA(XINPUT_GET_STATE_EX_ORDINAL)));
1988
4/4
✓ Branch 42 → 43 taken 80 times.
✓ Branch 42 → 45 taken 1 time.
✓ Branch 43 → 44 taken 79 times.
✓ Branch 43 → 45 taken 1 time.
81 const bool ex_is_distinct_member = get_state_ex != nullptr && get_state_ex != get_state;
1989
1990 // Construct the canonical cell before any reference or target patch.
1991
1/2
✗ Branch 47 → 48 not taken.
✓ Branch 47 → 49 taken 81 times.
81 if (!ensure_permanent_cell())
1992 {
1993 return false;
1994 }
1995
1996 // Take every keepalive before any prologue patch. The retention teardown has no allocator or loader call.
1997 // Fail closed and let the poll loop retry.
1998 162 s_xinput_permanent_hooks->self_ref =
1999 81 DetourModKit::detail::acquire_module_ref(diagnostics::ModulePinReason::XInputKeepalive);
2000
1/2
✗ Branch 50 → 51 not taken.
✓ Branch 50 → 52 taken 81 times.
81 if (s_xinput_permanent_hooks->self_ref == nullptr)
2001 {
2002 return false;
2003 }
2004 81 s_xinput_permanent_hooks->target_ref = acquire_module_ref_containing_address(get_state);
2005
1/2
✗ Branch 53 → 54 not taken.
✓ Branch 53 → 56 taken 81 times.
81 if (s_xinput_permanent_hooks->target_ref == nullptr)
2006 {
2007 release_xinput_module_refs();
2008 return false;
2009 }
2010
2/2
✓ Branch 56 → 57 taken 79 times.
✓ Branch 56 → 64 taken 2 times.
81 if (ex_is_distinct_member)
2011 {
2012 79 const HMODULE ex_target_ref = acquire_module_ref_containing_address(get_state_ex);
2013
1/2
✗ Branch 58 → 59 not taken.
✓ Branch 58 → 61 taken 79 times.
79 if (ex_target_ref == nullptr)
2014 {
2015 release_xinput_module_refs();
2016 return false;
2017 }
2018
2/2
✓ Branch 61 → 62 taken 78 times.
✓ Branch 61 → 63 taken 1 time.
79 if (ex_target_ref == s_xinput_permanent_hooks->target_ref)
2019 {
2020 // The primary pin already covers this prologue. Balance the duplicate probe reference now.
2021 78 DetourModKit::detail::release_module_ref(ex_target_ref, diagnostics::ModulePinReason::XInputTarget);
2022 }
2023 else
2024 {
2025 1 s_xinput_permanent_hooks->ex_target_ref = ex_target_ref;
2026 }
2027 }
2028
2029 // Reserve the worst case for BOTH members before either creation. A primary charge followed by an Ex refusal
2030 // strands the primary-only coverage that this transaction prevents.
2031 81 safetyhook::RouteRetentionCredit pair_credit = safetyhook::RouteRetentionCredit::acquire(2);
2032
2/2
✓ Branch 66 → 67 taken 1 time.
✓ Branch 66 → 75 taken 80 times.
81 if (!pair_credit)
2033 {
2034
1/2
✓ Branch 68 → 69 taken 1 time.
✗ Branch 68 → 73 not taken.
1 if (!s_xinput_capacity_warned.exchange(true, std::memory_order_relaxed))
2035 {
2036 2 (void)log().log_noexcept(
2037 LogLevel::Warning,
2038 1 "InputIntercept: the routed retention ceiling refused the XInput hook pair, "
2039 "so no XInput interception was installed and both entries remain open."
2040 );
2041 }
2042 1 release_xinput_module_refs();
2043 1 return false;
2044 }
2045
2046 // Create both members before either prologue patch. Before an arm runs, no thread enters a detour. A creation
2047 // failure rolls the whole transaction back and leaves both entries open.
2048
2/2
✓ Branch 76 → 77 taken 2 times.
✓ Branch 76 → 79 taken 78 times.
80 if (!create_disabled_xinput_hook(
2049 pair_credit,
2050 get_state,
2051 reinterpret_cast<void *>(&xinput_get_state_detour),
2052 80 s_xinput_permanent_hooks->primary
2053 ))
2054 {
2055 2 release_xinput_module_refs();
2056 2 return false;
2057 }
2058
4/6
✓ Branch 79 → 80 taken 76 times.
✓ Branch 79 → 83 taken 2 times.
✗ Branch 81 → 82 not taken.
✓ Branch 81 → 83 taken 76 times.
✗ Branch 84 → 85 not taken.
✓ Branch 84 → 88 taken 78 times.
154 if (ex_is_distinct_member && !create_disabled_xinput_hook(
2059 pair_credit,
2060 get_state_ex,
2061 reinterpret_cast<void *>(&xinput_get_state_ex_detour),
2062 76 s_xinput_permanent_hooks->ex
2063 ))
2064 {
2065 reset_inactive_xinput_hook(s_xinput_permanent_hooks->primary, s_xinput_original);
2066 release_xinput_module_refs();
2067 return false;
2068 }
2069
2070 156 const XInputArmOutcome primary_outcome = arm_created_xinput_hook(
2071 78 s_xinput_permanent_hooks->primary,
2072 s_xinput_original,
2073 s_xinput_enable_warned,
2074 78 "InputIntercept: XInputGetState hook transaction did not complete cleanly; state was "
2075 "reconciled from the target bytes."
2076 );
2077
2/2
✓ Branch 90 → 91 taken 1 time.
✓ Branch 90 → 97 taken 77 times.
78 if (primary_outcome == XInputArmOutcome::CommittedUnreachable)
2078 {
2079 // A game thread can hold this trampoline and nothing here can drain it. Retain the created, disabled
2080 // ordinal-100 member too. Recovery must arm that retained object rather than treat an empty slot as the
2081 // absent/alias exemption.
2082 const XInputPublishedChains published_chains{
2083 1 s_xinput_original.load(std::memory_order_seq_cst) != nullptr,
2084 2 s_xinput_ex_original.load(std::memory_order_seq_cst) != nullptr
2085 1 };
2086 1 retain_xinput_hooks(
2087 PatchWitness::Original,
2088 PatchWitness::Original,
2089 XInputRetentionReason::UnprovedInstall,
2090 deferred_log,
2091 published_chains
2092 );
2093 1 lock.unlock();
2094 1 emit_xinput_retention_log(deferred_log);
2095 1 return false;
2096 }
2097
2/2
✓ Branch 97 → 98 taken 1 time.
✓ Branch 97 → 102 taken 76 times.
77 if (primary_outcome != XInputArmOutcome::Armed)
2098 {
2099 1 reset_inactive_xinput_hook(s_xinput_permanent_hooks->ex, s_xinput_ex_original);
2100 1 reset_inactive_xinput_hook(s_xinput_permanent_hooks->primary, s_xinput_original);
2101 1 release_xinput_module_refs();
2102 1 return false;
2103 }
2104
2105
2/2
✓ Branch 102 → 103 taken 74 times.
✓ Branch 102 → 106 taken 2 times.
76 if (ex_is_distinct_member)
2106 {
2107 74 (void)arm_created_xinput_hook(
2108 74 s_xinput_permanent_hooks->ex,
2109 s_xinput_ex_original,
2110 s_xinput_ex_enable_warned,
2111 74 "InputIntercept: the XInputGetStateEx hook transaction did not complete cleanly, so XInput coverage "
2112 "stays degraded and both entries pass through."
2113 );
2114 }
2115
2116 // The final pair witness reads both prologues before the store that enables suppression. A member
2117 // restored by a rival writer degrades the pair and prevents publication of incomplete coverage.
2118
2/2
✓ Branch 107 → 108 taken 74 times.
✓ Branch 107 → 109 taken 2 times.
76 if (publish_xinput_pair_if_whole(
2119 76 s_xinput_permanent_hooks->primary,
2120 76 s_xinput_permanent_hooks->ex,
2121 user_index,
2122 owner
2123 ))
2124 {
2125 74 return true;
2126 }
2127 // Clear the gate rather than defer: the next poll cycle attempts the first recovery immediately.
2128 2 xinput_recovery_reset();
2129 2 return false;
2130 276 }
2131
2132 188 bool xinput_installed() noexcept
2133 {
2134 188 return s_xinput_installed.load(std::memory_order_acquire);
2135 }
2136
2137 33 XInputGetStateFn xinput_trampoline() noexcept
2138 {
2139
4/4
✓ Branch 3 → 4 taken 10 times.
✓ Branch 3 → 7 taken 23 times.
✓ Branch 8 → 9 taken 6 times.
✓ Branch 8 → 10 taken 27 times.
43 if (!s_xinput_installed.load(std::memory_order_acquire) &&
2140
2/2
✓ Branch 5 → 6 taken 6 times.
✓ Branch 5 → 7 taken 4 times.
10 !s_xinput_pair_degraded.load(std::memory_order_acquire))
2141 {
2142 6 return nullptr;
2143 }
2144 27 return s_xinput_original.load(std::memory_order_acquire);
2145 }
2146
2147 13 bool publish_gamepad_suppress(uint16_t suppress_bits, std::uint64_t owner) noexcept
2148 {
2149 13 run_data_plane_entry_seam();
2150 13 const DataPlaneLockGuard data_lock;
2151
2/2
✓ Branch 5 → 6 taken 3 times.
✓ Branch 5 → 7 taken 10 times.
13 if (!data_plane_authorized(owner))
2152 {
2153 3 return false;
2154 }
2155 // Write the deadline before the release store on the mask, so a fresh mask is never paired with a stale
2156 // deadline.
2157 10 s_suppress_deadline_ms.store(GetTickCount64() + SUPPRESS_TTL_MS, std::memory_order_relaxed);
2158 10 s_suppress_mask.store(suppress_bits, std::memory_order_release);
2159 10 return true;
2160 13 }
2161
2162 34 bool install_message_hook(std::uint64_t owner, std::uint32_t target_thread_id) noexcept
2163 {
2164 34 const InterceptLockGuard lock{s_intercept_mutex};
2165
3/6
✓ Branch 4 → 5 taken 34 times.
✗ Branch 4 → 6 not taken.
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 34 times.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 34 times.
34 if (!owner_available(owner) || target_thread_id == 0)
2166 {
2167 return false;
2168 }
2169 34 settle_message_hook_route_locked();
2170
2/2
✓ Branch 18 → 19 taken 1 time.
✓ Branch 18 → 20 taken 33 times.
34 if (s_msg_hook_route_state.load(std::memory_order_relaxed) ==
2171 static_cast<std::uint8_t>(WheelRouteState::CleanupBlocked))
2172 {
2173 // A prior removal failure still blocks new mounts. settle_message_hook_route_locked() clears this once
2174 // the blocked thread exits.
2175 1 return false;
2176 }
2177
2178 33 const HHOOK mounted = s_msg_hook.load(std::memory_order_relaxed);
2179
2/2
✓ Branch 21 → 22 taken 4 times.
✓ Branch 21 → 84 taken 29 times.
33 if (mounted != nullptr)
2180 {
2181
4/4
✓ Branch 29 → 30 taken 2 times.
✓ Branch 29 → 39 taken 2 times.
✓ Branch 40 → 41 taken 2 times.
✓ Branch 40 → 43 taken 2 times.
6 if (s_msg_hook_thread_id.load(std::memory_order_relaxed) == target_thread_id &&
2182
1/2
✓ Branch 37 → 38 taken 2 times.
✗ Branch 37 → 39 not taken.
2 s_msg_hook_route_state.load(std::memory_order_relaxed) ==
2183 static_cast<std::uint8_t>(WheelRouteState::Ready))
2184 {
2185 // Idempotent same-thread mount. A window change on the same thread needs no republish.
2186 2 publish_owner(owner);
2187 2 return true;
2188 }
2189 // Migration transaction: disable capture and consume, advance the epoch, drain admitted decisions,
2190 // then remove the old hook before the new hook mounts. Hooks never overlap.
2191 2 const std::uint64_t wheel_epoch = close_wheel_capture_and_advance_epoch();
2192 {
2193 2 const DataPlaneLockGuard data_lock;
2194 2 reset_wheel_data_plane(wheel_epoch);
2195 2 }
2196
1/2
✗ Branch 48 → 49 not taken.
✓ Branch 48 → 58 taken 2 times.
2 if (!drain_wheel_admitted_phases())
2197 {
2198 s_msg_hook_route_state.store(
2199 static_cast<std::uint8_t>(WheelRouteState::Retryable),
2200 std::memory_order_release
2201 );
2202 return false;
2203 }
2204
1/2
✗ Branch 59 → 60 not taken.
✓ Branch 59 → 61 taken 2 times.
2 if (msg_hook_target_thread_exited_locked())
2205 {
2206 retire_message_hook_route_locked();
2207 }
2208
1/2
✗ Branch 62 → 63 not taken.
✓ Branch 62 → 72 taken 2 times.
2 else if (!unhook_or_already_gone(mounted))
2209 {
2210 // Old-hook removal failed on a live thread. Block the new mount until that thread exits.
2211 s_msg_hook_route_state.store(
2212 static_cast<std::uint8_t>(WheelRouteState::CleanupBlocked),
2213 std::memory_order_release
2214 );
2215 return false;
2216 }
2217 else
2218 {
2219 2 s_msg_hook.store(nullptr, std::memory_order_release);
2220 s_msg_hook_thread_id.store(0, std::memory_order_release);
2221
1/2
✓ Branch 81 → 82 taken 2 times.
✗ Branch 81 → 84 not taken.
2 if (s_msg_hook_thread != nullptr)
2222 {
2223 2 CloseHandle(s_msg_hook_thread);
2224 2 s_msg_hook_thread = nullptr;
2225 }
2226 }
2227 }
2228 // Validate the target: it must belong to this process and be alive.
2229 31 HANDLE thread = OpenThread(SYNCHRONIZE | THREAD_QUERY_LIMITED_INFORMATION, FALSE, target_thread_id);
2230
2/2
✓ Branch 85 → 86 taken 1 time.
✓ Branch 85 → 95 taken 30 times.
31 if (thread == nullptr)
2231 {
2232 s_msg_hook_route_state.store(
2233 static_cast<std::uint8_t>(WheelRouteState::Retryable),
2234 std::memory_order_release
2235 );
2236 1 return false;
2237 }
2238
3/6
✓ Branch 97 → 98 taken 30 times.
✗ Branch 97 → 100 not taken.
✗ Branch 99 → 100 not taken.
✓ Branch 99 → 101 taken 30 times.
✗ Branch 102 → 103 not taken.
✓ Branch 102 → 113 taken 30 times.
30 if (GetProcessIdOfThread(thread) != GetCurrentProcessId() || WaitForSingleObject(thread, 0) == WAIT_OBJECT_0)
2239 {
2240 CloseHandle(thread);
2241 s_msg_hook_route_state.store(
2242 static_cast<std::uint8_t>(WheelRouteState::Retryable),
2243 std::memory_order_release
2244 );
2245 return false;
2246 }
2247
2248 // Take the keepalive before the hook is published. A successful publication makes it permanent because a
2249 // selected callback can run after UnhookWindowsHookEx returns. A failed publication releases it.
2250 30 HMODULE new_ref = nullptr;
2251
2/2
✓ Branch 114 → 115 taken 24 times.
✓ Branch 114 → 119 taken 6 times.
30 if (!s_msg_hook_ref_taken.load(std::memory_order_relaxed))
2252 {
2253 24 new_ref = acquire_module_ref(diagnostics::ModulePinReason::MessageHookKeepalive);
2254
1/2
✗ Branch 116 → 117 not taken.
✓ Branch 116 → 119 taken 24 times.
24 if (new_ref == nullptr)
2255 {
2256 CloseHandle(thread);
2257 return false;
2258 }
2259 }
2260
2261 30 const HHOOK hook = SetWindowsHookExW(WH_GETMESSAGE, &message_hook_proc, nullptr, target_thread_id);
2262
1/2
✗ Branch 120 → 121 not taken.
✓ Branch 120 → 132 taken 30 times.
30 if (hook == nullptr)
2263 {
2264 release_module_ref(new_ref, diagnostics::ModulePinReason::MessageHookKeepalive);
2265 CloseHandle(thread);
2266 s_msg_hook_route_state.store(
2267 static_cast<std::uint8_t>(WheelRouteState::Retryable),
2268 std::memory_order_release
2269 );
2270 return false;
2271 }
2272
2/2
✓ Branch 132 → 133 taken 24 times.
✓ Branch 132 → 135 taken 6 times.
30 if (new_ref != nullptr)
2273 {
2274 24 s_msg_hook_ref_taken.store(true, std::memory_order_relaxed);
2275 24 DetourModKit::diagnostics::record_intentional_leak(DetourModKit::diagnostics::LeakSubsystem::Input);
2276 }
2277 30 s_msg_hook_thread = thread;
2278 // A callback that races route publication passes through until this id becomes visible.
2279 s_msg_hook_thread_id.store(target_thread_id, std::memory_order_release);
2280 30 s_msg_hook.store(hook, std::memory_order_release);
2281
1/2
✓ Branch 144 → 145 taken 30 times.
✗ Branch 144 → 146 not taken.
30 s_msg_hook_mount_generation = s_msg_hook_mount_generation + 1 == 0 ? 1 : s_msg_hook_mount_generation + 1;
2282 s_msg_hook_route_state.store(static_cast<std::uint8_t>(WheelRouteState::Ready), std::memory_order_release);
2283 30 publish_owner(owner);
2284 30 return true;
2285 34 }
2286
2287 145 bool message_hook_installed() noexcept
2288 {
2289 145 return message_hook_route_state() == WheelRouteState::Ready;
2290 }
2291
2292 183 WheelRouteState message_hook_route_state() noexcept
2293 {
2294 183 const InterceptLockGuard lock{s_intercept_mutex};
2295 183 settle_message_hook_route_locked();
2296 183 return static_cast<WheelRouteState>(s_msg_hook_route_state.load(std::memory_order_relaxed));
2297 183 }
2298
2299 22 std::uint32_t message_hook_thread_id() noexcept
2300 {
2301 22 return s_msg_hook_thread_id.load(std::memory_order_acquire);
2302 }
2303
2304 6 std::uint64_t message_hook_mount_generation() noexcept
2305 {
2306 6 const InterceptLockGuard lock{s_intercept_mutex};
2307 12 return s_msg_hook_mount_generation;
2308 6 }
2309
2310 99 std::array<int, 4> take_wheel_counts(std::uint64_t owner) noexcept
2311 {
2312 99 run_data_plane_entry_seam();
2313 99 const DataPlaneLockGuard data_lock;
2314 99 std::array<int, 4> out{};
2315
2/2
✓ Branch 5 → 6 taken 11 times.
✓ Branch 5 → 7 taken 88 times.
99 if (!data_plane_authorized(owner))
2316 {
2317 11 return out;
2318 }
2319 88 const std::uint64_t wheel_epoch = wheel_capture_epoch(s_wheel_capture_state.load(std::memory_order_seq_cst));
2320
2/2
✓ Branch 25 → 16 taken 352 times.
✓ Branch 25 → 26 taken 88 times.
440 for (int dir = 0; dir < 4; ++dir)
2321 {
2322 352 const std::uint64_t packed = s_wheel_count[static_cast<size_t>(dir)].exchange(
2323 wheel_count_slot(wheel_epoch, 0),
2324 std::memory_order_relaxed
2325 );
2326
1/2
✓ Branch 21 → 22 taken 352 times.
✗ Branch 21 → 24 not taken.
352 if (wheel_slot_epoch(packed) == wheel_epoch)
2327 {
2328 352 out[static_cast<size_t>(dir)] = static_cast<int>(packed & WHEEL_COUNT_MASK);
2329 }
2330 }
2331 88 return out;
2332 99 }
2333
2334 #if defined(DMK_ENABLE_TEST_SEAMS)
2335 4 void seed_wheel_notches_for_test(const std::array<int, 4> &notches) noexcept
2336 {
2337 4 const std::uint64_t wheel_epoch = wheel_capture_epoch(s_wheel_capture_state.load(std::memory_order_seq_cst));
2338
2/2
✓ Branch 29 → 11 taken 16 times.
✓ Branch 29 → 30 taken 4 times.
40 for (size_t dir = 0; dir < s_wheel_count.size(); ++dir)
2339 {
2340 // Saturate to bump_wheel_notch's ceiling so a seeded backlog stays a producible state.
2341 16 int n = notches[dir];
2342
1/2
✗ Branch 12 → 13 not taken.
✓ Branch 12 → 14 taken 16 times.
16 if (n < 0)
2343 {
2344 n = 0;
2345 }
2346
1/2
✗ Branch 14 → 15 not taken.
✓ Branch 14 → 16 taken 16 times.
16 else if (n > MAX_WHEEL_NOTCHES)
2347 {
2348 n = MAX_WHEEL_NOTCHES;
2349 }
2350 16 s_wheel_count[dir].store(
2351 wheel_count_slot(wheel_epoch, static_cast<std::uint64_t>(n)),
2352 std::memory_order_relaxed
2353 );
2354 }
2355 4 }
2356 #endif
2357
2358 593 bool publish_wheel_consume(uint8_t direction_mask, bool require_focus, std::uint64_t owner) noexcept
2359 {
2360 593 run_data_plane_entry_seam();
2361 593 const DataPlaneLockGuard data_lock;
2362
2/2
✓ Branch 5 → 6 taken 526 times.
✓ Branch 5 → 7 taken 67 times.
593 if (!data_plane_authorized(owner))
2363 {
2364 526 return false;
2365 }
2366
2/2
✓ Branch 14 → 15 taken 1 time.
✓ Branch 14 → 16 taken 66 times.
67 if ((s_wheel_capture_state.load(std::memory_order_seq_cst) & WHEEL_CAPTURE_ENABLED) == 0)
2367 {
2368 1 return false;
2369 }
2370 66 s_wheel_require_focus.store(require_focus, std::memory_order_relaxed);
2371 // Refresh the deadline before the mask release store, but only for a nonzero arm. This order ensures a set
2372 // direction bit never appears with a stale deadline. A zero mask needs no deadline.
2373
2/2
✓ Branch 17 → 18 taken 18 times.
✓ Branch 17 → 28 taken 48 times.
66 if (direction_mask != 0)
2374 {
2375 18 s_wheel_consume_deadline_ms.store(GetTickCount64() + SUPPRESS_TTL_MS, std::memory_order_relaxed);
2376 }
2377 66 s_wheel_consume_mask.store(direction_mask, std::memory_order_release);
2378 66 return true;
2379 593 }
2380
2381 #if defined(DMK_ENABLE_TEST_SEAMS)
2382 12 void set_xinput_arm_seam(XInputArmSeam seam) noexcept
2383 {
2384 12 s_xinput_arm_seam.store(seam, std::memory_order_release);
2385 12 }
2386
2387 4 void set_xinput_detour_body_seam(XInputDetourBodySeam seam) noexcept
2388 {
2389 4 s_xinput_detour_body_seam.store(seam, std::memory_order_release);
2390 4 }
2391
2392 4 void set_xinput_route_entry_hold_for_test(bool hold) noexcept
2393 {
2394
2/2
✓ Branch 2 → 3 taken 2 times.
✓ Branch 2 → 4 taken 2 times.
4 safetyhook::set_route_park_for_test(
2395 hold ? safetyhook::RouteParkStage::BEFORE_DESTINATION : safetyhook::RouteParkStage::NONE
2396 );
2397 4 }
2398
2399 1866 bool xinput_route_entry_reached_for_test() noexcept
2400 {
2401 1866 return safetyhook::route_park_reached_for_test();
2402 }
2403
2404 2 void set_xinput_clean_release_seam(XInputCleanReleaseSeam seam) noexcept
2405 {
2406 2 s_xinput_clean_release_seam.store(seam, std::memory_order_release);
2407 2 }
2408
2409 2 void set_xinput_retention_attribution_seam(XInputRetentionAttributionSeam seam) noexcept
2410 {
2411 2 s_xinput_retention_attribution_seam.store(seam, std::memory_order_release);
2412 2 }
2413
2414 2 void set_xinput_create_seam(XInputCreateSeam seam) noexcept
2415 {
2416 2 s_xinput_create_seam.store(seam, std::memory_order_release);
2417 2 }
2418
2419 20 void set_xinput_backend_toggle_exception_for_test(void *target, bool after_mutation) noexcept
2420 {
2421
2/2
✓ Branch 2 → 3 taken 10 times.
✓ Branch 2 → 6 taken 10 times.
20 if (target == nullptr)
2422 {
2423 10 safetyhook::g_trap_exception_stage_override.store(
2424 safetyhook::TrapExceptionStage::NONE,
2425 std::memory_order_release
2426 );
2427 10 safetyhook::g_trap_exception_target_override.store(nullptr, std::memory_order_relaxed);
2428 10 return;
2429 }
2430
2431 s_xinput_backend_toggle_exception_catches.store(0, std::memory_order_relaxed);
2432 10 safetyhook::g_trap_exception_target_override.store(
2433 static_cast<std::uint8_t *>(target),
2434 std::memory_order_relaxed
2435 );
2436
2/2
✓ Branch 15 → 16 taken 2 times.
✓ Branch 15 → 17 taken 8 times.
10 safetyhook::g_trap_exception_stage_override.store(
2437 after_mutation ? safetyhook::TrapExceptionStage::AFTER_MUTATION
2438 : safetyhook::TrapExceptionStage::BEFORE_MUTATION,
2439 std::memory_order_release
2440 );
2441 }
2442
2443 7 std::size_t xinput_backend_toggle_exception_catches_for_test() noexcept
2444 {
2445 7 return s_xinput_backend_toggle_exception_catches.load(std::memory_order_relaxed);
2446 }
2447
2448 9 bool xinput_permanent_primary_retained() noexcept
2449 {
2450
4/6
✓ Branch 3 → 4 taken 5 times.
✓ Branch 3 → 8 taken 4 times.
✓ Branch 4 → 5 taken 5 times.
✗ Branch 4 → 8 not taken.
✓ Branch 6 → 7 taken 5 times.
✗ Branch 6 → 8 not taken.
14 return s_xinput_permanent_detour.load(std::memory_order_acquire) && s_xinput_permanent_hooks != nullptr &&
2451 14 static_cast<bool>(s_xinput_permanent_hooks->primary);
2452 }
2453
2454 61 int xinput_module_refs_held() noexcept
2455 {
2456
4/4
✓ Branch 2 → 3 taken 60 times.
✓ Branch 2 → 13 taken 1 time.
✓ Branch 3 → 4 taken 33 times.
✓ Branch 3 → 5 taken 27 times.
61 return s_xinput_permanent_hooks != nullptr ? (s_xinput_permanent_hooks->self_ref != nullptr ? 1 : 0) +
2457
2/2
✓ Branch 6 → 7 taken 33 times.
✓ Branch 6 → 8 taken 27 times.
60 (s_xinput_permanent_hooks->target_ref != nullptr ? 1 : 0) +
2458
2/2
✓ Branch 9 → 10 taken 1 time.
✓ Branch 9 → 11 taken 59 times.
60 (s_xinput_permanent_hooks->ex_target_ref != nullptr ? 1 : 0)
2459 61 : 0;
2460 }
2461
2462 1 void arm_xinput_process_exit_oracle_for_test(const std::uint8_t *target) noexcept
2463 {
2464
1/2
✗ Branch 2 → 3 not taken.
✓ Branch 2 → 4 taken 1 time.
1 if (target == nullptr)
2465 {
2466 return;
2467 }
2468
2/2
✓ Branch 6 → 5 taken 16 times.
✓ Branch 6 → 7 taken 1 time.
17 for (std::size_t i = 0; i < XINPUT_PROCESS_EXIT_WITNESS_BYTES; ++i)
2469 {
2470 16 s_xinput_process_exit_patch[i] = target[i];
2471 }
2472 1 s_xinput_process_exit_target.store(target, std::memory_order_release);
2473 }
2474
2475 149 void set_xinput_module_override_for_test(HMODULE module) noexcept
2476 {
2477 149 const InterceptLockGuard lock{s_intercept_mutex};
2478 149 s_xinput_module_override = module;
2479 149 }
2480
2481 23 XInputGetStateFn xinput_ex_trampoline() noexcept
2482 {
2483 23 return s_xinput_ex_original.load(std::memory_order_acquire);
2484 }
2485
2486 12 void apply_xinput_suppress_for_test(XINPUT_STATE *state, DWORD user_index) noexcept
2487 {
2488 12 apply_suppress(state, user_index);
2489 12 }
2490
2491 4 int xinput_bound_user_index() noexcept
2492 {
2493 4 return s_bound_user_index.load(std::memory_order_relaxed);
2494 }
2495
2496 12 void set_data_plane_entry_seam(DataPlaneEntrySeam seam) noexcept
2497 {
2498 12 s_data_plane_entry_seam.store(seam, std::memory_order_release);
2499 12 }
2500
2501 4 void set_wheel_capture_entry_seam(WheelCaptureEntrySeam seam) noexcept
2502 {
2503 4 s_wheel_capture_entry_seam.store(seam, std::memory_order_release);
2504 4 }
2505
2506 32 void set_wheel_finalize_entry_seam(WheelFinalizeEntrySeam seam) noexcept
2507 {
2508 32 s_wheel_finalize_entry_seam.store(seam, std::memory_order_release);
2509 32 }
2510
2511 2 void set_wheel_drain_timeout_for_test(std::uint64_t timeout_ms) noexcept
2512 {
2513 s_wheel_drain_timeout_override_ms.store(timeout_ms, std::memory_order_release);
2514 2 }
2515
2516 32 void set_message_unhook_failure_for_test(bool fail) noexcept
2517 {
2518 32 s_force_message_unhook_failure.store(fail, std::memory_order_release);
2519 32 }
2520
2521 34 void set_wheel_process_focus_for_test(std::int32_t focused) noexcept
2522 {
2523 s_wheel_process_focus_override.store(focused, std::memory_order_release);
2524 34 }
2525
2526 2 std::uint32_t wheel_admitted_phases_for_test() noexcept
2527 {
2528 2 return s_wheel_admitted_phases.load(std::memory_order_seq_cst);
2529 }
2530
2531 27 bool process_wheel_message_for_test(bool horizontal, int delta) noexcept
2532 {
2533 27 const std::uint64_t capture_state = s_wheel_capture_state.load(std::memory_order_seq_cst);
2534 27 const bool consume_intent = wheel_count_admission(horizontal, delta, capture_state);
2535
3/4
✓ Branch 10 → 11 taken 4 times.
✓ Branch 10 → 14 taken 23 times.
✓ Branch 12 → 13 taken 4 times.
✗ Branch 12 → 14 not taken.
27 return consume_intent && wheel_consume_finalization(horizontal, delta, capture_state);
2536 }
2537
2538 2 std::uint32_t consume_rules_sequence() noexcept
2539 {
2540 2 return s_consume_rules_seq.load(std::memory_order_acquire);
2541 }
2542
2543 1 std::uint16_t gamepad_suppress_mask_for_test() noexcept
2544 {
2545 1 return s_suppress_mask.load(std::memory_order_acquire);
2546 }
2547
2548 1 bool gamepad_rule_suppress_enabled_for_test() noexcept
2549 {
2550 1 return s_rule_suppress_enabled.load(std::memory_order_acquire);
2551 }
2552
2553 2 std::uint8_t wheel_consume_mask_for_test() noexcept
2554 {
2555 2 return s_wheel_consume_mask.load(std::memory_order_acquire);
2556 }
2557 #endif
2558
2559 463 void uninstall(std::uint64_t owner) noexcept
2560 {
2561 463 InterceptLockGuard lock{s_intercept_mutex};
2562 463 XInputRetentionLog deferred_log;
2563
5/6
✓ Branch 3 → 4 taken 463 times.
✗ Branch 3 → 12 not taken.
✓ Branch 11 → 12 taken 310 times.
✓ Branch 11 → 13 taken 153 times.
✓ Branch 14 → 15 taken 310 times.
✓ Branch 14 → 16 taken 153 times.
926 if (owner == 0 || s_intercept_owner.load(std::memory_order_relaxed) != owner)
2564 {
2565 310 return;
2566 }
2567
2568 // revoke_owner_and_clear_data() atomically revokes the layer and clears its data before teardown. The active
2569 // InterceptLockGuard serializes teardown. Data-plane writers use s_data_plane_mutex, so a revoked owner cannot
2570 // publish live state.
2571 153 revoke_owner_and_clear_data();
2572
2573 153 uninstall_message_hook();
2574
2575
2/2
✓ Branch 19 → 20 taken 9 times.
✓ Branch 19 → 27 taken 144 times.
153 if (s_xinput_permanent_detour.load(std::memory_order_acquire))
2576 {
2577 // A prior timeout or uncertain restore made the canonical hooks permanent.
2578 // This call only disarms the logical layer. A reachable retained entry continues to forward.
2579 9 s_xinput_installed.store(false, std::memory_order_release);
2580 9 s_xinput_pair_degraded.store(false, std::memory_order_release);
2581 9 xinput_recovery_reset();
2582 9 s_xinput_enable_warned.store(false, std::memory_order_relaxed);
2583 9 s_xinput_ex_enable_warned.store(false, std::memory_order_relaxed);
2584 9 s_xinput_capacity_warned.store(false, std::memory_order_relaxed);
2585 9 return;
2586 }
2587
2588
2/2
✓ Branch 27 → 28 taken 69 times.
✓ Branch 27 → 32 taken 75 times.
144 if (s_xinput_permanent_hooks == nullptr)
2589 {
2590 69 s_xinput_installed.store(false, std::memory_order_release);
2591 69 s_xinput_pair_degraded.store(false, std::memory_order_release);
2592 69 xinput_recovery_reset();
2593 69 return;
2594 }
2595
2596 // Close backend admission before pointer retirement. This covers the interval before InflightGuard and the
2597 // body it counts. The exit structure resolves both routes from their byte witnesses. Retention exits use
2598 // retain_xinput_hooks. The clean exit uses reset_inactive_xinput_hook.
2599 75 s_xinput_permanent_hooks->ex.begin_route_rundown();
2600 75 s_xinput_permanent_hooks->primary.begin_route_rundown();
2601
2602 // Retire the published trampoline pointers before the drain. A late entrant sees nullptr instead of a pointer
2603 // into a hook near destruction. seq_cst places these stores and the drain load in the detour total order.
2604 const XInputPublishedChains published_chains{
2605 75 s_xinput_original.load(std::memory_order_seq_cst) != nullptr,
2606 150 s_xinput_ex_original.load(std::memory_order_seq_cst) != nullptr
2607 75 };
2608 75 s_xinput_ex_original.store(nullptr, std::memory_order_seq_cst);
2609 75 s_xinput_original.store(nullptr, std::memory_order_seq_cst);
2610
2611 // Quiesce detours that already copied a trampoline. Use a wall-clock bound, not a yield count. A hot game
2612 // thread can enter after pointer retirement, and teardown must still progress.
2613 75 constexpr uint64_t xinput_quiesce_timeout_ms = 10;
2614 75 const uint64_t quiesce_deadline_ms = GetTickCount64() + xinput_quiesce_timeout_ms;
2615
2/2
✓ Branch 50 → 51 taken 73 times.
✓ Branch 50 → 53 taken 23387 times.
23535 while ((s_xinput_inflight.load(std::memory_order_seq_cst) != 0 ||
2616
1/2
✗ Branch 52 → 53 not taken.
✓ Branch 52 → 56 taken 73 times.
23533 s_xinput_permanent_hooks->primary.route_entries() != 0 ||
2617
4/4
✓ Branch 48 → 49 taken 23460 times.
✓ Branch 48 → 53 taken 15044 times.
✓ Branch 57 → 40 taken 38429 times.
✓ Branch 57 → 58 taken 75 times.
77081 s_xinput_permanent_hooks->ex.route_entries() != 0) &&
2618
2/2
✓ Branch 54 → 55 taken 38429 times.
✓ Branch 54 → 56 taken 2 times.
38431 GetTickCount64() < quiesce_deadline_ms)
2619 {
2620 38429 std::this_thread::yield();
2621 }
2622
2623
2/2
✓ Branch 67 → 68 taken 73 times.
✓ Branch 67 → 70 taken 1 time.
74 const bool route_still_inflight = s_xinput_inflight.load(std::memory_order_seq_cst) != 0 ||
2624
3/4
✓ Branch 65 → 66 taken 74 times.
✓ Branch 65 → 70 taken 1 time.
✗ Branch 69 → 70 not taken.
✓ Branch 69 → 71 taken 73 times.
149 s_xinput_permanent_hooks->primary.route_entries() != 0 ||
2625 73 s_xinput_permanent_hooks->ex.route_entries() != 0;
2626
2/2
✓ Branch 72 → 73 taken 2 times.
✓ Branch 72 → 79 taken 73 times.
75 if (route_still_inflight)
2627 {
2628 2 retain_xinput_hooks(
2629 2 xinput_teardown_witness(s_xinput_permanent_hooks->primary),
2630 2 xinput_teardown_witness(s_xinput_permanent_hooks->ex),
2631 XInputRetentionReason::InflightTimeout,
2632 deferred_log,
2633 published_chains
2634 );
2635 2 lock.unlock();
2636 2 emit_xinput_retention_log(deferred_log);
2637 2 return;
2638 }
2639
2640 // Classify both targets before either backend restore. If a newer layer owns either prologue, refuse the whole
2641 // pair rather than overwrite that layer with a partial teardown.
2642 73 const PatchWitness primary_before = xinput_teardown_witness(s_xinput_permanent_hooks->primary);
2643 73 const PatchWitness ex_before = xinput_teardown_witness(s_xinput_permanent_hooks->ex);
2644
5/6
✓ Branch 82 → 83 taken 70 times.
✓ Branch 82 → 85 taken 3 times.
✗ Branch 84 → 85 not taken.
✓ Branch 84 → 86 taken 70 times.
✓ Branch 87 → 88 taken 3 times.
✓ Branch 87 → 92 taken 70 times.
73 if (!witness_permits_write(primary_before) || !witness_permits_write(ex_before))
2645 {
2646 3 retain_xinput_hooks(primary_before, ex_before, XInputRetentionReason::UnrestoredPatch, deferred_log);
2647 3 lock.unlock();
2648 3 emit_xinput_retention_log(deferred_log);
2649 3 return;
2650 }
2651
2652 // Restore Ex first, then primary. A caught exception can occur before or after either mutation, so the byte
2653 // witness after each call is authoritative. Only Original permits the hook object and keepalives to be freed.
2654 70 const PatchWitness ex_after = restore_xinput_hook(s_xinput_permanent_hooks->ex);
2655
2/2
✓ Branch 93 → 94 taken 1 time.
✓ Branch 93 → 98 taken 69 times.
70 if (ex_after != PatchWitness::Original)
2656 {
2657 1 retain_xinput_hooks(primary_before, ex_after, XInputRetentionReason::UnrestoredPatch, deferred_log);
2658 1 lock.unlock();
2659 1 emit_xinput_retention_log(deferred_log);
2660 1 return;
2661 }
2662 69 const PatchWitness primary_after = restore_xinput_hook(s_xinput_permanent_hooks->primary);
2663
2/2
✓ Branch 99 → 100 taken 4 times.
✓ Branch 99 → 109 taken 65 times.
69 if (primary_after != PatchWitness::Original)
2664 {
2665 // The pair is one transaction and this half did not commit. Put the Ex member back first. Immediate
2666 // retention publishes a primary-only chain and permanently drops the covered ordinal-100 entry point.
2667 // Compensation reuses the current hook, and its own witness gate declines rather than fight a newer
2668 // writer.
2669 const PatchWitness ex_compensated =
2670 8 rearm_xinput_hook(
2671 4 s_xinput_permanent_hooks->ex,
2672 s_xinput_ex_original,
2673 s_xinput_ex_enable_warned,
2674 4 "InputIntercept: the XInputGetStateEx re-arm that compensates a refused primary "
2675 "restore did not complete cleanly; Ex state was reconciled from the target bytes."
2676 )
2677
2/2
✓ Branch 102 → 103 taken 3 times.
✓ Branch 102 → 104 taken 1 time.
4 ? xinput_teardown_witness(s_xinput_permanent_hooks->ex)
2678 1 : PatchWitness::Original;
2679 4 retain_xinput_hooks(primary_after, ex_compensated, XInputRetentionReason::UnrestoredPatch, deferred_log);
2680 4 lock.unlock();
2681 4 emit_xinput_retention_log(deferred_log);
2682 4 return;
2683 }
2684
2685 #if defined(DMK_ENABLE_TEST_SEAMS)
2686
2/2
✓ Branch 110 → 111 taken 1 time.
✓ Branch 110 → 112 taken 64 times.
65 if (const XInputCleanReleaseSeam seam = s_xinput_clean_release_seam.load(std::memory_order_acquire);
2687 seam != nullptr)
2688 {
2689 1 seam();
2690 }
2691 #endif
2692
2693 65 reset_inactive_xinput_hook(s_xinput_permanent_hooks->ex, s_xinput_ex_original);
2694 65 reset_inactive_xinput_hook(s_xinput_permanent_hooks->primary, s_xinput_original);
2695
2696 // No detour code remains active. A later install_xinput() takes a fresh pair.
2697 65 release_xinput_module_refs();
2698
2699 65 s_xinput_installed.store(false, std::memory_order_release);
2700 65 s_xinput_pair_degraded.store(false, std::memory_order_release);
2701 65 xinput_recovery_reset();
2702 // Re-arm the enable()-failure latches so a fresh install after a hot-reload can warn again.
2703 65 s_xinput_enable_warned.store(false, std::memory_order_relaxed);
2704 65 s_xinput_ex_enable_warned.store(false, std::memory_order_relaxed);
2705 65 s_xinput_capacity_warned.store(false, std::memory_order_relaxed);
2706
2/2
✓ Branch 123 → 124 taken 65 times.
✓ Branch 123 → 126 taken 398 times.
463 }
2707
2708 } // namespace DetourModKit::detail
2709