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> ¬ches) 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 |