src/internal/input_intercept.cpp
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | /** | ||
| 2 | * @file input_intercept.cpp | ||
| 3 | * @brief XInput route maintenance and thread-scoped wheel capture. | ||
| 4 | */ | ||
| 5 | |||
| 6 | #include "input_intercept.hpp" | ||
| 7 | #include "internal/drain_backoff.hpp" | ||
| 8 | #include "internal/hook_backend_visit.hpp" | ||
| 9 | #include "internal/hook_patch_witness.hpp" | ||
| 10 | #include "internal/xinput_raw_scope.hpp" | ||
| 11 | #include "internal/xinput_route_probe.hpp" | ||
| 12 | #include "platform.hpp" | ||
| 13 | #include "DetourModKit/diagnostics.hpp" | ||
| 14 | #include "DetourModKit/logger.hpp" | ||
| 15 | |||
| 16 | #include <safetyhook.hpp> | ||
| 17 | |||
| 18 | #include <algorithm> | ||
| 19 | #include <array> | ||
| 20 | #include <atomic> | ||
| 21 | #include <chrono> | ||
| 22 | #include <charconv> | ||
| 23 | #include <cstdint> | ||
| 24 | #include <cstring> | ||
| 25 | #include <limits> | ||
| 26 | #include <memory> | ||
| 27 | #include <new> | ||
| 28 | #include <optional> | ||
| 29 | #include <span> | ||
| 30 | #include <string_view> | ||
| 31 | #include <thread> | ||
| 32 | #include <type_traits> | ||
| 33 | #include <utility> | ||
| 34 | |||
| 35 | namespace DetourModKit::detail | ||
| 36 | { | ||
| 37 | namespace | ||
| 38 | { | ||
| 39 | /// The game or runtime determines which DLL contains the XInput export. | ||
| 40 | constexpr const wchar_t *XINPUT_DLL_NAMES[] = { | ||
| 41 | L"xinput1_4.dll", | ||
| 42 | L"xinput1_3.dll", | ||
| 43 | L"xinput9_1_0.dll", | ||
| 44 | L"xinput1_2.dll", | ||
| 45 | L"xinput1_1.dll", | ||
| 46 | }; | ||
| 47 | |||
| 48 | /// Identifies the undocumented ordinal that exports XInputGetStateEx and reports the Guide button. | ||
| 49 | constexpr WORD XINPUT_GET_STATE_EX_ORDINAL = 100; | ||
| 50 | |||
| 51 | /** | ||
| 52 | * @brief Names the position of each pair member across generations and DMK copies. | ||
| 53 | * @details A create claims a Vacant position with this identity. The names are part of the cross-copy | ||
| 54 | * protocol and must not change. | ||
| 55 | */ | ||
| 56 | constexpr safetyhook::PositionClaim XINPUT_GET_STATE_POSITION = | ||
| 57 | safetyhook::PositionClaim::from_name("DetourModKit.XInputGetState"); | ||
| 58 | constexpr safetyhook::PositionClaim XINPUT_GET_STATE_EX_POSITION = | ||
| 59 | safetyhook::PositionClaim::from_name("DetourModKit.XInputGetStateEx"); | ||
| 60 | |||
| 61 | /** | ||
| 62 | * @brief Defines how long a published suppression mask stays valid without a refresh. | ||
| 63 | * @details Twice MAX_POLL_INTERVAL leaves a full poll interval for cycle work before expiry. A stalled poll | ||
| 64 | * thread still loses suppression after a bounded delay. | ||
| 65 | */ | ||
| 66 | constexpr uint64_t SUPPRESS_TTL_MS = 2000; | ||
| 67 | |||
| 68 | // One owner per layer: hooks and keepalives are shared per linked DMK instance, and the token prevents | ||
| 69 | // superseded poller teardown of a newer installation. Static SRWLOCK storage has no destructor, so late | ||
| 70 | // process teardown cannot encounter a destroyed mutex. | ||
| 71 | SRWLOCK s_intercept_mutex = SRWLOCK_INIT; | ||
| 72 | // Makes the owner check and its authorized write one indivisible step. Separate locks let a revoked poller | ||
| 73 | // overwrite the new owner's state. Acquired after s_intercept_mutex wherever both are held. The detours take | ||
| 74 | // neither lock. | ||
| 75 | SRWLOCK s_data_plane_mutex = SRWLOCK_INIT; | ||
| 76 | std::atomic<std::uint64_t> s_intercept_owner{0}; | ||
| 77 | std::atomic<std::uint64_t> s_xinput_hook_epoch{1}; | ||
| 78 | bool s_xinput_raw_owner{false}; | ||
| 79 | bool s_xinput_observed_pair{false}; | ||
| 80 | bool s_xinput_primary_observed{false}; | ||
| 81 | bool s_xinput_ex_observed{false}; | ||
| 82 | bool s_xinput_route_warned{false}; | ||
| 83 | std::atomic<std::uint64_t> s_next_intercept_owner{STANDALONE_INTERCEPT_OWNER + 1}; | ||
| 84 | constexpr unsigned WHEEL_COUNT_BITS = 11; | ||
| 85 | constexpr std::uint64_t WHEEL_COUNT_MASK = (std::uint64_t{1} << WHEEL_COUNT_BITS) - 1; | ||
| 86 | constexpr std::uint64_t WHEEL_EPOCH_MAX = | ||
| 87 | (std::uint64_t{1} << (std::numeric_limits<std::uint64_t>::digits - WHEEL_COUNT_BITS)) - 1; | ||
| 88 | constexpr std::uint64_t WHEEL_CAPTURE_ENABLED = 1; | ||
| 89 | static_assert(MAX_WHEEL_NOTCHES <= WHEEL_COUNT_MASK); | ||
| 90 | |||
| 91 | 333 | [[nodiscard]] constexpr std::uint64_t wheel_capture_state(std::uint64_t epoch, bool enabled) noexcept | |
| 92 | { | ||
| 93 | 333 | return (epoch << 1) | (enabled ? WHEEL_CAPTURE_ENABLED : 0); | |
| 94 | } | ||
| 95 | |||
| 96 | 2903 | [[nodiscard]] constexpr std::uint64_t wheel_capture_epoch(std::uint64_t state) noexcept | |
| 97 | { | ||
| 98 | 2903 | return state >> 1; | |
| 99 | } | ||
| 100 | |||
| 101 | 2820 | [[nodiscard]] constexpr std::uint64_t wheel_count_slot(std::uint64_t epoch, std::uint64_t count) noexcept | |
| 102 | { | ||
| 103 | 2820 | return (epoch << WHEEL_COUNT_BITS) | count; | |
| 104 | } | ||
| 105 | |||
| 106 | 1600 | [[nodiscard]] constexpr std::uint64_t wheel_slot_epoch(std::uint64_t slot) noexcept | |
| 107 | { | ||
| 108 | 1600 | return slot >> WHEEL_COUNT_BITS; | |
| 109 | } | ||
| 110 | |||
| 111 | // The per-axis signed sub-notch remainder includes the capture epoch and consume-ownership state. A fragment | ||
| 112 | // with a different (epoch, owned) tag resets the remainder to zero. A retired epoch fragment and an | ||
| 113 | // owned/unowned fragment pair cannot combine into one notch (WheelDeltaTest.*). | ||
| 114 | constexpr unsigned WHEEL_REMAINDER_BITS = 8; | ||
| 115 | constexpr int WHEEL_REMAINDER_BIAS = 128; | ||
| 116 | constexpr std::uint64_t WHEEL_REMAINDER_VALUE_MASK = (std::uint64_t{1} << WHEEL_REMAINDER_BITS) - 1; | ||
| 117 | constexpr std::uint64_t WHEEL_REMAINDER_OWNED_BIT = std::uint64_t{1} << WHEEL_REMAINDER_BITS; | ||
| 118 | constexpr unsigned WHEEL_REMAINDER_EPOCH_SHIFT = WHEEL_REMAINDER_BITS + 1; | ||
| 119 | static_assert(WHEEL_DELTA < WHEEL_REMAINDER_BIAS, "a sub-notch remainder must fit the biased value field"); | ||
| 120 | static_assert(WHEEL_EPOCH_MAX <= (std::numeric_limits<std::uint64_t>::max() >> WHEEL_REMAINDER_EPOCH_SHIFT)); | ||
| 121 | |||
| 122 | [[nodiscard]] constexpr std::uint64_t | ||
| 123 | 1879 | wheel_remainder_slot(std::uint64_t epoch, bool owned, int remainder) noexcept | |
| 124 | { | ||
| 125 | 1879 | return (epoch << WHEEL_REMAINDER_EPOCH_SHIFT) | (owned ? WHEEL_REMAINDER_OWNED_BIT : 0) | | |
| 126 | 1879 | static_cast<std::uint64_t>(remainder + WHEEL_REMAINDER_BIAS); | |
| 127 | } | ||
| 128 | |||
| 129 | std::atomic<std::uint64_t> s_wheel_capture_state{wheel_capture_state(1, false)}; | ||
| 130 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 131 | std::atomic<WheelCaptureEntrySeam> s_wheel_capture_entry_seam{nullptr}; | ||
| 132 | std::atomic<WheelFinalizeEntrySeam> s_wheel_finalize_entry_seam{nullptr}; | ||
| 133 | std::atomic<XInputRetentionAttributionSeam> s_xinput_retention_attribution_seam{nullptr}; | ||
| 134 | std::atomic<std::uint64_t> s_wheel_drain_timeout_override_ms{0}; | ||
| 135 | std::atomic<bool> s_force_message_unhook_failure{false}; | ||
| 136 | std::atomic<std::int32_t> s_wheel_process_focus_override{-1}; | ||
| 137 | #endif | ||
| 138 | |||
| 139 | /** @brief Owns exclusive access to the process-lifetime interception lock. */ | ||
| 140 | class InterceptLockGuard | ||
| 141 | { | ||
| 142 | public: | ||
| 143 | 16304 | explicit InterceptLockGuard(SRWLOCK &mutex) noexcept : m_mutex(mutex) { AcquireSRWLockExclusive(&m_mutex); } | |
| 144 | |||
| 145 | 16304 | ~InterceptLockGuard() noexcept | |
| 146 | { | ||
| 147 | 16304 | if (m_locked) | |
| 148 | { | ||
| 149 | 14433 | ReleaseSRWLockExclusive(&m_mutex); | |
| 150 | } | ||
| 151 | 16304 | } | |
| 152 | |||
| 153 | InterceptLockGuard(const InterceptLockGuard &) = delete; | ||
| 154 | InterceptLockGuard &operator=(const InterceptLockGuard &) = delete; | ||
| 155 | InterceptLockGuard(InterceptLockGuard &&) = delete; | ||
| 156 | InterceptLockGuard &operator=(InterceptLockGuard &&) = delete; | ||
| 157 | |||
| 158 | /// Releases the lock before this guard leaves scope. | ||
| 159 | 3144 | void unlock() noexcept | |
| 160 | { | ||
| 161 | 3144 | if (m_locked) | |
| 162 | { | ||
| 163 | 3144 | ReleaseSRWLockExclusive(&m_mutex); | |
| 164 | 3144 | m_locked = false; | |
| 165 | } | ||
| 166 | 3144 | } | |
| 167 | |||
| 168 | /// Reacquires the lock after an upstream call returns. | ||
| 169 | 1273 | void relock() noexcept | |
| 170 | { | ||
| 171 | 1273 | if (!m_locked) | |
| 172 | { | ||
| 173 | 1273 | AcquireSRWLockExclusive(&m_mutex); | |
| 174 | 1273 | m_locked = true; | |
| 175 | } | ||
| 176 | 1273 | } | |
| 177 | |||
| 178 | private: | ||
| 179 | SRWLOCK &m_mutex; | ||
| 180 | bool m_locked{true}; | ||
| 181 | }; | ||
| 182 | |||
| 183 | /** | ||
| 184 | * @brief Requires s_intercept_mutex. Reports whether @p owner can claim the layer. | ||
| 185 | * @details This claim predicate admits the unowned layer, which is invalid as write authorization. The | ||
| 186 | * data_plane_authorized() predicate requires an exact owner match. | ||
| 187 | */ | ||
| 188 | 2051 | [[nodiscard]] bool owner_available(std::uint64_t owner) noexcept | |
| 189 | { | ||
| 190 | 2051 | if (owner == 0) | |
| 191 | { | ||
| 192 | 1 | return false; | |
| 193 | } | ||
| 194 | 2050 | const std::uint64_t current = s_intercept_owner.load(std::memory_order_relaxed); | |
| 195 | 2050 | return current == 0 || current == owner; | |
| 196 | } | ||
| 197 | |||
| 198 | /// Requires s_data_plane_mutex. Reports whether @p owner can write the state that detours read. | ||
| 199 | 7969 | [[nodiscard]] bool data_plane_authorized(std::uint64_t owner) noexcept | |
| 200 | { | ||
| 201 | 15938 | return owner != 0 && s_intercept_owner.load(std::memory_order_relaxed) == owner; | |
| 202 | } | ||
| 203 | |||
| 204 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 205 | std::atomic<DataPlaneEntrySeam> s_data_plane_entry_seam{nullptr}; | ||
| 206 | #endif | ||
| 207 | |||
| 208 | /// Runs the entry probe, if any, before a data-plane operation takes its lock. | ||
| 209 | 7969 | void run_data_plane_entry_seam() noexcept | |
| 210 | { | ||
| 211 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 212 | 7969 | if (const DataPlaneEntrySeam seam = s_data_plane_entry_seam.load(std::memory_order_acquire); | |
| 213 | seam != nullptr) | ||
| 214 | { | ||
| 215 | 6 | seam(); | |
| 216 | } | ||
| 217 | #endif | ||
| 218 | 7969 | } | |
| 219 | |||
| 220 | /** @brief Owns exclusive access to the process-lifetime data-plane lock. */ | ||
| 221 | class DataPlaneLockGuard | ||
| 222 | { | ||
| 223 | public: | ||
| 224 | 11308 | DataPlaneLockGuard() noexcept { AcquireSRWLockExclusive(&s_data_plane_mutex); } | |
| 225 | |||
| 226 | 11308 | ~DataPlaneLockGuard() noexcept { ReleaseSRWLockExclusive(&s_data_plane_mutex); } | |
| 227 | |||
| 228 | DataPlaneLockGuard(const DataPlaneLockGuard &) = delete; | ||
| 229 | DataPlaneLockGuard &operator=(const DataPlaneLockGuard &) = delete; | ||
| 230 | DataPlaneLockGuard(DataPlaneLockGuard &&) = delete; | ||
| 231 | DataPlaneLockGuard &operator=(DataPlaneLockGuard &&) = delete; | ||
| 232 | }; | ||
| 233 | |||
| 234 | /// Requires s_data_plane_mutex. Clears every mask and rule that the prior owner armed. | ||
| 235 | void clear_data_plane_locked(std::uint64_t wheel_epoch) noexcept; | ||
| 236 | |||
| 237 | /** | ||
| 238 | * @brief Requires s_intercept_mutex. Reports whether wheel capture can open. | ||
| 239 | * @details True with no mounted wheel hook, where an open capture is inert, or on a Ready route. A hook that a | ||
| 240 | * failed migration or removal left on a Retryable or CleanupBlocked route keeps capture closed. | ||
| 241 | */ | ||
| 242 | [[nodiscard]] bool wheel_capture_armable_locked() noexcept; | ||
| 243 | |||
| 244 | /** | ||
| 245 | * @brief Closes wheel capture and advances its epoch so an already-entered frame cannot write into a | ||
| 246 | * successor. | ||
| 247 | * @return The newly published disabled epoch. | ||
| 248 | */ | ||
| 249 | 333 | [[nodiscard]] std::uint64_t close_wheel_capture_and_advance_epoch() noexcept | |
| 250 | { | ||
| 251 | 333 | std::uint64_t state = s_wheel_capture_state.load(std::memory_order_seq_cst); | |
| 252 | for (;;) | ||
| 253 | { | ||
| 254 | 333 | const std::uint64_t epoch = wheel_capture_epoch(state); | |
| 255 | 333 | const std::uint64_t next_epoch = epoch == WHEEL_EPOCH_MAX ? 1 : epoch + 1; | |
| 256 | 333 | const std::uint64_t desired = wheel_capture_state(next_epoch, false); | |
| 257 | 333 | if (s_wheel_capture_state.compare_exchange_weak(state, desired, std::memory_order_seq_cst)) | |
| 258 | { | ||
| 259 | 333 | return next_epoch; | |
| 260 | } | ||
| 261 | ✗ | } | |
| 262 | } | ||
| 263 | |||
| 264 | /** | ||
| 265 | * @brief Requires s_intercept_mutex. Publishes the owner after an installation is ready and re-opens wheel | ||
| 266 | * capture for it when wheel_capture_armable_locked() allows it. | ||
| 267 | * @details The arm action occurs here, not at each install site, so ownership itself controls the association. | ||
| 268 | * The Ready store in install_message_hook publishes again, so a route that later becomes Ready arms. | ||
| 269 | */ | ||
| 270 | 3006 | void publish_owner(std::uint64_t owner) noexcept | |
| 271 | { | ||
| 272 | 3006 | const bool arm = wheel_capture_armable_locked(); | |
| 273 | 3006 | const DataPlaneLockGuard data_lock; | |
| 274 | 3006 | if (s_intercept_owner.load(std::memory_order_relaxed) != owner) | |
| 275 | { | ||
| 276 | s_xinput_hook_epoch.fetch_add(1, std::memory_order_seq_cst); | ||
| 277 | 324 | s_xinput_observed_pair = false; | |
| 278 | 324 | s_xinput_primary_observed = false; | |
| 279 | 324 | s_xinput_ex_observed = false; | |
| 280 | 324 | s_xinput_route_warned = false; | |
| 281 | } | ||
| 282 | s_intercept_owner.store(owner, std::memory_order_release); | ||
| 283 | 3006 | if (arm) | |
| 284 | { | ||
| 285 | s_wheel_capture_state.fetch_or(WHEEL_CAPTURE_ENABLED, std::memory_order_seq_cst); | ||
| 286 | } | ||
| 287 | 3006 | } | |
| 288 | |||
| 289 | /** | ||
| 290 | * @brief Requires s_intercept_mutex. Revokes the layer and clears the data that the prior owner armed. | ||
| 291 | * @details One step ensures an unowned layer retains no live mask that lacks an owner with revoke authority. A | ||
| 292 | * wheel-capture epoch advance invalidates an active message-hook frame without a wait. | ||
| 293 | */ | ||
| 294 | 323 | void revoke_owner_and_clear_data() noexcept | |
| 295 | { | ||
| 296 | s_xinput_hook_epoch.fetch_add(1, std::memory_order_seq_cst); | ||
| 297 | 323 | close_xinput_route_probes(); | |
| 298 | 323 | s_xinput_observed_pair = false; | |
| 299 | 323 | s_xinput_primary_observed = false; | |
| 300 | 323 | s_xinput_ex_observed = false; | |
| 301 | 323 | s_xinput_route_warned = false; | |
| 302 | 323 | const std::uint64_t wheel_epoch = close_wheel_capture_and_advance_epoch(); | |
| 303 | 323 | const DataPlaneLockGuard data_lock; | |
| 304 | 323 | clear_data_plane_locked(wheel_epoch); | |
| 305 | s_intercept_owner.store(0, std::memory_order_release); | ||
| 306 | 323 | } | |
| 307 | |||
| 308 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 309 | // This oracle precedes the hook cell, so its destructor runs after later automatic objects. | ||
| 310 | // The exit proof arms it with a patched target window. | ||
| 311 | constexpr std::size_t XINPUT_PROCESS_EXIT_WITNESS_BYTES = 16; | ||
| 312 | std::atomic<const std::uint8_t *> s_xinput_process_exit_target{nullptr}; | ||
| 313 | std::uint8_t s_xinput_process_exit_patch[XINPUT_PROCESS_EXIT_WITNESS_BYTES]{}; | ||
| 314 | class XInputProcessExitOracle | ||
| 315 | { | ||
| 316 | public: | ||
| 317 | ✗ | ~XInputProcessExitOracle() noexcept | |
| 318 | { | ||
| 319 | ✗ | const std::uint8_t *const target = s_xinput_process_exit_target.load(std::memory_order_acquire); | |
| 320 | ✗ | if (target == nullptr) | |
| 321 | { | ||
| 322 | ✗ | return; | |
| 323 | } | ||
| 324 | ✗ | for (std::size_t i = 0; i < XINPUT_PROCESS_EXIT_WITNESS_BYTES; ++i) | |
| 325 | { | ||
| 326 | ✗ | if (target[i] != s_xinput_process_exit_patch[i]) | |
| 327 | { | ||
| 328 | ✗ | ::RaiseFailFastException(nullptr, nullptr, 0); | |
| 329 | } | ||
| 330 | } | ||
| 331 | } | ||
| 332 | }; | ||
| 333 | XInputProcessExitOracle s_xinput_process_exit_oracle; | ||
| 334 | #endif | ||
| 335 | std::atomic<XInputGetStateFn> s_xinput_original{nullptr}; | ||
| 336 | std::atomic<XInputGetStateFn> s_xinput_ex_original{nullptr}; | ||
| 337 | std::atomic<bool> s_xinput_installed{false}; | ||
| 338 | std::atomic<bool> s_xinput_pair_degraded{false}; | ||
| 339 | // This flag is true after a timeout or unproved restore latches the XInput hooks in process-lifetime storage. A | ||
| 340 | // later Input start re-arms only through the retained primary entry, never over uncertain storage. | ||
| 341 | std::atomic<bool> s_xinput_permanent_detour{false}; | ||
| 342 | // One-shot diagnostic latches prevent sink spam because install_xinput retries every poll cycle. uninstall() | ||
| 343 | // clears them so a later hot-reload re-arm can warn again. | ||
| 344 | std::atomic<bool> s_xinput_enable_warned{false}; | ||
| 345 | std::atomic<bool> s_xinput_ex_enable_warned{false}; | ||
| 346 | std::atomic<bool> s_xinput_capacity_warned{false}; | ||
| 347 | // This raw cell owns all XInput hooks and keepalives for the process lifetime. | ||
| 348 | // A normal teardown resets its hooks and releases its module references. | ||
| 349 | // A veto leaves the cell intact, so the CRT runs no InlineHook destructor under the loader lock. | ||
| 350 | struct PermanentXInputHooks | ||
| 351 | { | ||
| 352 | safetyhook::InlineHook primary; | ||
| 353 | safetyhook::InlineHook ex; | ||
| 354 | /// The gateway entry of each claimed member. The claimed witness compares the target with a jump to it. | ||
| 355 | const std::uint8_t *primary_gateway{nullptr}; | ||
| 356 | const std::uint8_t *ex_gateway{nullptr}; | ||
| 357 | HMODULE self_ref{nullptr}; | ||
| 358 | HMODULE target_ref{nullptr}; | ||
| 359 | HMODULE ex_target_ref{nullptr}; | ||
| 360 | }; | ||
| 361 | alignas(PermanentXInputHooks) unsigned char s_xinput_permanent_cell[sizeof(PermanentXInputHooks)]; | ||
| 362 | static_assert( | ||
| 363 | std::is_trivially_destructible_v<decltype(s_xinput_permanent_cell)>, | ||
| 364 | "the raw XInput placement cell must have no automatic destructor" | ||
| 365 | ); | ||
| 366 | bool s_xinput_permanent_cell_ready{false}; | ||
| 367 | PermanentXInputHooks *s_xinput_permanent_hooks{nullptr}; | ||
| 368 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 369 | std::atomic<std::size_t> s_xinput_backend_toggle_exception_catches{0}; | ||
| 370 | #endif | ||
| 371 | |||
| 372 | /** | ||
| 373 | * @brief Returns the reserved cell as a live object. | ||
| 374 | * @return Pointer to the object that ensure_permanent_cell() constructed. | ||
| 375 | * @note Requires s_intercept_mutex and a prior successful ensure_permanent_cell(). | ||
| 376 | */ | ||
| 377 | 312 | [[nodiscard]] PermanentXInputHooks *permanent_cell() noexcept | |
| 378 | { | ||
| 379 | 312 | return std::launder(reinterpret_cast<PermanentXInputHooks *>(s_xinput_permanent_cell)); | |
| 380 | } | ||
| 381 | |||
| 382 | /** | ||
| 383 | * @brief Constructs the reserved cell before an XInput detour is published. | ||
| 384 | * @note Requires s_intercept_mutex. MSVC debug-container proxy setup can allocate, so this runs before the | ||
| 385 | * allocation-free teardown boundary. | ||
| 386 | */ | ||
| 387 | 212 | [[nodiscard]] bool ensure_permanent_cell() noexcept | |
| 388 | { | ||
| 389 | 212 | if (s_xinput_permanent_cell_ready) | |
| 390 | { | ||
| 391 | 68 | return true; | |
| 392 | } | ||
| 393 | try | ||
| 394 | { | ||
| 395 | 144 | ::new (static_cast<void *>(s_xinput_permanent_cell)) PermanentXInputHooks{}; | |
| 396 | } | ||
| 397 | catch (...) | ||
| 398 | { | ||
| 399 | return false; | ||
| 400 | } | ||
| 401 | 144 | s_xinput_permanent_cell_ready = true; | |
| 402 | 144 | s_xinput_permanent_hooks = permanent_cell(); | |
| 403 | 144 | return true; | |
| 404 | } | ||
| 405 | |||
| 406 | /** | ||
| 407 | * @brief Identifies the evidence that resets the recovery deadline for an incomplete XInput pair. | ||
| 408 | * @details The delay grows to a cap and stays there (the poll loop asks every cycle, and the target really | ||
| 409 | * can come back). Changed evidence drops the accumulated delay, so recovery occurs before a stale | ||
| 410 | * backoff expires. | ||
| 411 | * @note Requires s_intercept_mutex. | ||
| 412 | */ | ||
| 413 | struct XInputRecoveryEvidence | ||
| 414 | { | ||
| 415 | /// Identifies the module whose prologue the pair patches. A different pin identifies a different target. | ||
| 416 | 1374 | const void *target_module{nullptr}; | |
| 417 | /// Identifies the owner that requests recovery. A poller restart supplies fresh evidence. | ||
| 418 | 406 | std::uint64_t owner{0}; | |
| 419 | /// Identifies the absent member. Either member can be absent, so the recovery cadence keys on both. | ||
| 420 | 406 | bool primary_covered{false}; | |
| 421 | 406 | bool ex_covered{false}; | |
| 422 | /** | ||
| 423 | * @brief Records whether each target's current bytes permit the re-arm write. | ||
| 424 | * @details A guarded byte comparison detects when a concurrent writer returns an export. The comparison | ||
| 425 | * does not perform the protection change that the delay bounds. | ||
| 426 | */ | ||
| 427 | 405 | bool primary_target_writable{false}; | |
| 428 | 404 | bool ex_target_writable{false}; | |
| 429 | |||
| 430 | 1374 | [[nodiscard]] bool operator==(const XInputRecoveryEvidence &) const noexcept = default; | |
| 431 | }; | ||
| 432 | |||
| 433 | /// Defines the first delay after a failed recovery transaction. | ||
| 434 | inline constexpr std::uint64_t XINPUT_RECOVERY_MIN_DELAY_MS = 32; | ||
| 435 | /// Defines the delay ceiling. Recovery retries at this cadence while the pair remains broken. | ||
| 436 | inline constexpr std::uint64_t XINPUT_RECOVERY_MAX_DELAY_MS = 2000; | ||
| 437 | |||
| 438 | XInputRecoveryEvidence s_xinput_recovery_evidence{}; | ||
| 439 | std::uint64_t s_xinput_recovery_delay_ms{0}; | ||
| 440 | std::uint64_t s_xinput_recovery_not_before_ms{0}; | ||
| 441 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 442 | std::atomic<std::size_t> s_xinput_recovery_attempts{0}; | ||
| 443 | #endif | ||
| 444 | |||
| 445 | /// Requires s_intercept_mutex. Reports whether a recovery transaction can run for @p evidence now. | ||
| 446 | 1374 | [[nodiscard]] bool xinput_recovery_due(const XInputRecoveryEvidence &evidence) noexcept | |
| 447 | { | ||
| 448 | 1374 | if (evidence != s_xinput_recovery_evidence) | |
| 449 | { | ||
| 450 | 971 | s_xinput_recovery_evidence = evidence; | |
| 451 | 971 | s_xinput_recovery_delay_ms = 0; | |
| 452 | 971 | s_xinput_recovery_not_before_ms = 0; | |
| 453 | 971 | return true; | |
| 454 | } | ||
| 455 | 403 | return GetTickCount64() >= s_xinput_recovery_not_before_ms; | |
| 456 | } | ||
| 457 | |||
| 458 | /// Requires s_intercept_mutex. Grows the delay toward its cap after a recovery transaction did not complete. | ||
| 459 | 975 | void xinput_recovery_deferred() noexcept | |
| 460 | { | ||
| 461 | 975 | s_xinput_recovery_delay_ms = | |
| 462 | 975 | (s_xinput_recovery_delay_ms == 0) | |
| 463 | 978 | ? XINPUT_RECOVERY_MIN_DELAY_MS | |
| 464 | 10 | : (s_xinput_recovery_delay_ms >= XINPUT_RECOVERY_MAX_DELAY_MS / 2 ? XINPUT_RECOVERY_MAX_DELAY_MS | |
| 465 | 7 | : s_xinput_recovery_delay_ms * 2); | |
| 466 | 975 | s_xinput_recovery_not_before_ms = GetTickCount64() + s_xinput_recovery_delay_ms; | |
| 467 | 975 | } | |
| 468 | |||
| 469 | /// Requires s_intercept_mutex. Clears the gate so the next incomplete pair starts from an immediate attempt. | ||
| 470 | 1783 | void xinput_recovery_reset() noexcept | |
| 471 | { | ||
| 472 | 1783 | s_xinput_recovery_evidence = {}; | |
| 473 | 1783 | s_xinput_recovery_delay_ms = 0; | |
| 474 | 1783 | s_xinput_recovery_not_before_ms = 0; | |
| 475 | 1783 | } | |
| 476 | |||
| 477 | /// Counts one recovery transaction for the proofs and passes its outcome straight through. | ||
| 478 | 982 | [[nodiscard]] bool record_xinput_recovery_attempt(bool armed) noexcept | |
| 479 | { | ||
| 480 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 481 | s_xinput_recovery_attempts.fetch_add(1, std::memory_order_relaxed); | ||
| 482 | #endif | ||
| 483 | 982 | return armed; | |
| 484 | } | ||
| 485 | |||
| 486 | std::atomic<int> s_bound_user_index{0}; | ||
| 487 | std::atomic<uint16_t> s_suppress_mask{0}; | ||
| 488 | std::atomic<uint64_t> s_suppress_deadline_ms{0}; | ||
| 489 | |||
| 490 | // This counter tracks game threads inside an XInput detour body. Before hook destruction, uninstall() retires | ||
| 491 | // the published trampoline pointers and drains this counter. This complements SafetyHook's mid-prologue | ||
| 492 | // relocation. | ||
| 493 | std::atomic<int> s_xinput_inflight{0}; | ||
| 494 | |||
| 495 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 496 | std::atomic<XInputDetourBodySeam> s_xinput_detour_body_seam{nullptr}; | ||
| 497 | std::atomic<XInputArmSeam> s_xinput_arm_seam{nullptr}; | ||
| 498 | std::atomic<XInputCleanReleaseSeam> s_xinput_clean_release_seam{nullptr}; | ||
| 499 | std::atomic<XInputCreateSeam> s_xinput_create_seam{nullptr}; | ||
| 500 | // When set, install_xinput resolves XInputGetState from this module so a test can drive the install against | ||
| 501 | // a synthetic proxy DLL. Set/cleared only on the test thread while no install runs. | ||
| 502 | HMODULE s_xinput_module_override{nullptr}; | ||
| 503 | #endif | ||
| 504 | |||
| 505 | /// Runs the arm-boundary probe between the backend toggle and the witness read that judges it. | ||
| 506 | 307 | void run_xinput_arm_seam() noexcept | |
| 507 | { | ||
| 508 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 509 | 307 | if (const XInputArmSeam seam = s_xinput_arm_seam.load(std::memory_order_acquire); seam != nullptr) | |
| 510 | { | ||
| 511 | 16 | seam(); | |
| 512 | } | ||
| 513 | #endif | ||
| 514 | 307 | } | |
| 515 | |||
| 516 | /// Names the code that a retired XInput chain can still execute. Each reachable provider keeps its reference. | ||
| 517 | enum class XInputChainReach : std::uint8_t | ||
| 518 | { | ||
| 519 | /// No chain stays, so every install reference goes. | ||
| 520 | Nothing, | ||
| 521 | /// A bypassed chain forwards to the XInput provider and reaches no code of this image. | ||
| 522 | Provider, | ||
| 523 | /// A retained chain can reach the provider and the detours of this image. | ||
| 524 | ProviderAndImage, | ||
| 525 | }; | ||
| 526 | |||
| 527 | /// Maps a reset that retained its chain to the reach of that chain. | ||
| 528 | 116 | [[nodiscard]] constexpr XInputChainReach reset_chain_reach(bool retained) noexcept | |
| 529 | { | ||
| 530 | 116 | return retained ? XInputChainReach::ProviderAndImage : XInputChainReach::Nothing; | |
| 531 | } | ||
| 532 | |||
| 533 | /// Releases the install references that @p reach does not need. A kept reference is never released. | ||
| 534 | 203 | void retire_xinput_module_refs(XInputChainReach reach = XInputChainReach::Nothing) noexcept | |
| 535 | { | ||
| 536 | 203 | if (s_xinput_permanent_hooks == nullptr) | |
| 537 | { | ||
| 538 | ✗ | return; | |
| 539 | } | ||
| 540 | 203 | if (reach != XInputChainReach::ProviderAndImage) | |
| 541 | { | ||
| 542 | 197 | if (s_xinput_raw_owner) | |
| 543 | { | ||
| 544 | 196 | release_xinput_raw_scope_owner(); | |
| 545 | 196 | s_xinput_raw_owner = false; | |
| 546 | } | ||
| 547 | 197 | close_xinput_route_probes(); | |
| 548 | 197 | (void)release_xinput_route_probe_if_idle(); | |
| 549 | } | ||
| 550 | 203 | if (reach == XInputChainReach::Nothing) | |
| 551 | { | ||
| 552 | 170 | DetourModKit::detail::release_module_ref( | |
| 553 | s_xinput_permanent_hooks->ex_target_ref, | ||
| 554 | diagnostics::ModulePinReason::XInputTarget | ||
| 555 | ); | ||
| 556 | 170 | DetourModKit::detail::release_module_ref( | |
| 557 | s_xinput_permanent_hooks->target_ref, | ||
| 558 | diagnostics::ModulePinReason::XInputTarget | ||
| 559 | ); | ||
| 560 | } | ||
| 561 | 203 | if (reach != XInputChainReach::ProviderAndImage) | |
| 562 | { | ||
| 563 | 197 | DetourModKit::detail::release_module_ref( | |
| 564 | s_xinput_permanent_hooks->self_ref, | ||
| 565 | diagnostics::ModulePinReason::XInputKeepalive | ||
| 566 | ); | ||
| 567 | } | ||
| 568 | 203 | s_xinput_permanent_hooks->ex_target_ref = nullptr; | |
| 569 | 203 | s_xinput_permanent_hooks->target_ref = nullptr; | |
| 570 | 203 | s_xinput_permanent_hooks->self_ref = nullptr; | |
| 571 | } | ||
| 572 | |||
| 573 | /// Returns the cached gateway entry of the cell member @p hook. | ||
| 574 | 6096 | [[nodiscard]] const std::uint8_t *claimed_gateway(const safetyhook::InlineHook &hook) noexcept | |
| 575 | { | ||
| 576 | 6096 | return &hook == &s_xinput_permanent_hooks->ex ? s_xinput_permanent_hooks->ex_gateway | |
| 577 | 6096 | : s_xinput_permanent_hooks->primary_gateway; | |
| 578 | } | ||
| 579 | |||
| 580 | /** | ||
| 581 | * @brief Classifies the target window of a claimed member. | ||
| 582 | * @details A routed position patches a `jmp rel32` to its gateway and fills the rest of the window with | ||
| 583 | * `nop`. Only the owner of the gateway jumps to it, so that exact encoding is the patch of this member. | ||
| 584 | */ | ||
| 585 | 6096 | [[nodiscard]] PatchWitness claimed_xinput_witness(const safetyhook::InlineHook &hook) noexcept | |
| 586 | { | ||
| 587 | 6096 | constexpr std::size_t JUMP_SIZE = 5; | |
| 588 | 6096 | const auto &original = hook.original_bytes(); | |
| 589 | 6096 | const std::size_t count = original.size(); | |
| 590 | 6096 | std::array<std::uint8_t, BACKEND_MAX_STEAL_WINDOW> current{}; | |
| 591 | 18288 | if (count < JUMP_SIZE || count > current.size() || | |
| 592 | 6096 | !guarded_read_bytes(hook.target_address(), current.data(), count)) | |
| 593 | { | ||
| 594 | ✗ | return PatchWitness::Indeterminate; | |
| 595 | } | ||
| 596 | 6096 | if (std::equal(original.begin(), original.end(), current.begin())) | |
| 597 | { | ||
| 598 | ✗ | return PatchWitness::Original; | |
| 599 | } | ||
| 600 | 6096 | const std::uint8_t *const gateway = claimed_gateway(hook); | |
| 601 | 12192 | const std::intptr_t displacement = reinterpret_cast<std::intptr_t>(gateway) - | |
| 602 | 6096 | static_cast<std::intptr_t>(hook.target_address() + JUMP_SIZE); | |
| 603 | 12192 | if (gateway == nullptr || displacement < std::numeric_limits<std::int32_t>::min() || | |
| 604 | 6096 | displacement > std::numeric_limits<std::int32_t>::max()) | |
| 605 | { | ||
| 606 | ✗ | return PatchWitness::Foreign; | |
| 607 | } | ||
| 608 | 6096 | std::array<std::uint8_t, BACKEND_MAX_STEAL_WINDOW> jump{}; | |
| 609 | 6096 | jump.fill(0x90); | |
| 610 | 6096 | jump[0] = 0xE9; | |
| 611 | 6096 | const auto relative = static_cast<std::int32_t>(displacement); | |
| 612 | 6096 | std::memcpy(jump.data() + 1, &relative, sizeof(relative)); | |
| 613 | 6096 | return std::equal(jump.begin(), jump.begin() + static_cast<std::ptrdiff_t>(count), current.begin()) | |
| 614 | 6096 | ? PatchWitness::OwnedPatch | |
| 615 | 6096 | : PatchWitness::Foreign; | |
| 616 | } | ||
| 617 | |||
| 618 | 19848 | [[nodiscard]] PatchWitness xinput_patch_witness(const safetyhook::InlineHook &hook) noexcept | |
| 619 | { | ||
| 620 | 19848 | if (!hook) | |
| 621 | { | ||
| 622 | 1 | return PatchWitness::Original; | |
| 623 | } | ||
| 624 | 19847 | return hook.claimed() ? claimed_xinput_witness(hook) : witness_patch(hook); | |
| 625 | } | ||
| 626 | |||
| 627 | 631 | [[nodiscard]] PatchWitness xinput_teardown_witness(const safetyhook::InlineHook &hook) noexcept | |
| 628 | { | ||
| 629 | // A reconciled disabled backend has no target entry into its trampoline. Treat retained inactive storage | ||
| 630 | // as Original even if its old target was later repatched or unmapped. | ||
| 631 | 631 | return hook && hook.enabled() ? xinput_patch_witness(hook) : PatchWitness::Original; | |
| 632 | } | ||
| 633 | |||
| 634 | 421 | [[nodiscard]] bool try_xinput_backend_enable(safetyhook::InlineHook &hook) noexcept | |
| 635 | { | ||
| 636 | try | ||
| 637 | { | ||
| 638 | 421 | return hook.enable().has_value(); | |
| 639 | } | ||
| 640 | 6 | catch (...) | |
| 641 | { | ||
| 642 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 643 | s_xinput_backend_toggle_exception_catches.fetch_add(1, std::memory_order_relaxed); | ||
| 644 | #endif | ||
| 645 | 6 | return false; | |
| 646 | 6 | } | |
| 647 | } | ||
| 648 | |||
| 649 | 214 | [[nodiscard]] bool try_xinput_backend_disable(safetyhook::InlineHook &hook) noexcept | |
| 650 | { | ||
| 651 | try | ||
| 652 | { | ||
| 653 | 214 | return hook.disable().has_value(); | |
| 654 | } | ||
| 655 | 6 | catch (...) | |
| 656 | { | ||
| 657 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 658 | s_xinput_backend_toggle_exception_catches.fetch_add(1, std::memory_order_relaxed); | ||
| 659 | #endif | ||
| 660 | 6 | return false; | |
| 661 | 6 | } | |
| 662 | } | ||
| 663 | |||
| 664 | /** | ||
| 665 | * @brief Classifies one raw-hook arm by whether a prologue mutation reached the target. | ||
| 666 | * @details Uncommitted is the only outcome whose storage the caller can destroy. CommittedUnreachable wrote | ||
| 667 | * the prologue and then lost it. A thread already inside can still reach the trampoline. | ||
| 668 | */ | ||
| 669 | enum class XInputArmOutcome : std::uint8_t | ||
| 670 | { | ||
| 671 | Armed, | ||
| 672 | Uncommitted, | ||
| 673 | CommittedUnreachable | ||
| 674 | }; | ||
| 675 | |||
| 676 | /** @brief Arms a freshly created raw hook and reconciles a failed transaction from its target bytes. */ | ||
| 677 | 422 | [[nodiscard]] XInputArmOutcome arm_xinput_hook( | |
| 678 | safetyhook::InlineHook &hook, | ||
| 679 | std::atomic<XInputGetStateFn> &original, | ||
| 680 | bool original_was_published, | ||
| 681 | std::atomic<bool> &warning_latch, | ||
| 682 | std::string_view warning | ||
| 683 | ) noexcept | ||
| 684 | { | ||
| 685 | 422 | const PatchWitness before = xinput_patch_witness(hook); | |
| 686 | 422 | if (hook.claimed()) | |
| 687 | { | ||
| 688 | // A claimed member writes no target byte, so its switch store is the whole arm. Original bytes no | ||
| 689 | // longer reach the claimed position. | ||
| 690 | 114 | if (before != PatchWitness::Original && try_xinput_backend_enable(hook)) | |
| 691 | { | ||
| 692 | 114 | return XInputArmOutcome::Armed; | |
| 693 | } | ||
| 694 | ✗ | if (!original_was_published) | |
| 695 | { | ||
| 696 | ✗ | original.store(nullptr, std::memory_order_release); | |
| 697 | } | ||
| 698 | ✗ | return XInputArmOutcome::Uncommitted; | |
| 699 | } | ||
| 700 | // An arm over the patch of another layer recaptures it and becomes the newest layer (`[B-110]`). | ||
| 701 | 308 | const bool layered = before == PatchWitness::Foreign && hook.window_holds_layer(); | |
| 702 | 308 | if (!witness_permits_write(before) && !layered) | |
| 703 | { | ||
| 704 | 1 | hook.reconcile_enabled(false); | |
| 705 | 1 | if (!original_was_published) | |
| 706 | { | ||
| 707 | ✗ | original.store(nullptr, std::memory_order_release); | |
| 708 | } | ||
| 709 | 1 | return XInputArmOutcome::Uncommitted; | |
| 710 | } | ||
| 711 | |||
| 712 | 307 | const bool backend_enabled = try_xinput_backend_enable(hook); | |
| 713 | 307 | run_xinput_arm_seam(); | |
| 714 | 307 | const bool mutation_committed = hook.enabled(); | |
| 715 | 307 | const PatchWitness after = xinput_patch_witness(hook); | |
| 716 | 307 | if (!mutation_committed || after == PatchWitness::Original) | |
| 717 | { | ||
| 718 | 17 | hook.reconcile_enabled(false); | |
| 719 | 17 | if (!mutation_committed && !original_was_published) | |
| 720 | { | ||
| 721 | 3 | original.store(nullptr, std::memory_order_release); | |
| 722 | } | ||
| 723 | 22 | if ((!backend_enabled || after != PatchWitness::Original) && | |
| 724 | 5 | !warning_latch.exchange(true, std::memory_order_relaxed)) | |
| 725 | { | ||
| 726 | 5 | (void)log().log_noexcept(LogLevel::Warning, warning); | |
| 727 | } | ||
| 728 | // A committed mutation routed callers through this trampoline before another writer restored the | ||
| 729 | // bytes. No new caller can arrive, but this code still must not free the storage. | ||
| 730 | 17 | return mutation_committed ? XInputArmOutcome::CommittedUnreachable : XInputArmOutcome::Uncommitted; | |
| 731 | } | ||
| 732 | |||
| 733 | // OwnedPatch is exact reachability. Foreign and Indeterminate cannot disprove a newer chain through this | ||
| 734 | // trampoline, so they retain the same conservative enabled state. | ||
| 735 | 290 | hook.reconcile_enabled(true); | |
| 736 | 291 | if ((!backend_enabled || after != PatchWitness::OwnedPatch) && | |
| 737 | 1 | !warning_latch.exchange(true, std::memory_order_relaxed)) | |
| 738 | { | ||
| 739 | 1 | (void)log().log_noexcept(LogLevel::Warning, warning); | |
| 740 | } | ||
| 741 | 290 | return XInputArmOutcome::Armed; | |
| 742 | } | ||
| 743 | |||
| 744 | /// Returns the gateway entry of the process record that a claimed @p hook adopted, or nullptr. | ||
| 745 | 114 | [[nodiscard]] const std::uint8_t *claimed_gateway_entry(const safetyhook::InlineHook &hook) noexcept | |
| 746 | { | ||
| 747 | 114 | const safetyhook::ProcessCoordinator coordinator; | |
| 748 | 114 | std::array<safetyhook::ProcessRouteRecord, 32> records{}; | |
| 749 | 114 | const std::size_t count = coordinator ? coordinator.records_at(hook.target(), records) : 0; | |
| 750 | 164 | for (std::size_t index = 0; index < count; ++index) | |
| 751 | { | ||
| 752 | 164 | if (records[index].trampoline.begin == hook.trampoline().data()) | |
| 753 | { | ||
| 754 | 114 | return records[index].gateway.begin; | |
| 755 | } | ||
| 756 | } | ||
| 757 | ✗ | return nullptr; | |
| 758 | 114 | } | |
| 759 | |||
| 760 | /** | ||
| 761 | * @brief Creates one disabled raw hook and contains allocation exceptions from creation. | ||
| 762 | * @details Creation remains separate from the arm, so the whole pair exists before any prologue patch. A | ||
| 763 | * creation failure rolls back completely and publishes nothing. The create claims a Vacant | ||
| 764 | * @p position first and then stores the claimed gateway in @p gateway. | ||
| 765 | */ | ||
| 766 | 402 | [[nodiscard]] bool create_disabled_xinput_hook( | |
| 767 | safetyhook::RouteRetentionCredit &credit, | ||
| 768 | void *target, | ||
| 769 | void *detour, | ||
| 770 | safetyhook::PositionClaim position, | ||
| 771 | safetyhook::InlineHook &destination, | ||
| 772 | const std::uint8_t *&gateway | ||
| 773 | ) noexcept | ||
| 774 | { | ||
| 775 | try | ||
| 776 | { | ||
| 777 | // One fresh arena per pair member keeps the pre-reserved block worst case independent. | ||
| 778 | 402 | const std::shared_ptr<safetyhook::Allocator> allocator = safetyhook::Allocator::create(); | |
| 779 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 780 | 401 | if (const XInputCreateSeam seam = s_xinput_create_seam.load(std::memory_order_acquire); seam != nullptr) | |
| 781 | { | ||
| 782 | 1 | seam(); | |
| 783 | } | ||
| 784 | #endif | ||
| 785 | auto created = safetyhook::InlineHook::create( | ||
| 786 | allocator, | ||
| 787 | target, | ||
| 788 | detour, | ||
| 789 | static_cast<safetyhook::InlineHook::Flags>( | ||
| 790 | safetyhook::InlineHook::StartDisabled | safetyhook::InlineHook::RoutedExternal | ||
| 791 | ), | ||
| 792 | credit, | ||
| 793 | position | ||
| 794 | 401 | ); | |
| 795 | 400 | if (!created) | |
| 796 | { | ||
| 797 | ✗ | return false; | |
| 798 | } | ||
| 799 | // The move assigns onto an empty destination, so it frees nothing and takes no allocation. | ||
| 800 | 800 | destination = std::move(created.value()); | |
| 801 | 400 | gateway = destination.claimed() ? claimed_gateway_entry(destination) : nullptr; | |
| 802 | 400 | return true; | |
| 803 | 401 | } | |
| 804 | 2 | catch (...) | |
| 805 | { | ||
| 806 | 2 | return false; | |
| 807 | 2 | } | |
| 808 | } | ||
| 809 | |||
| 810 | /** @brief Restores one raw hook only while its pre-write witness authorizes the backend mutation. */ | ||
| 811 | 237 | [[nodiscard]] PatchWitness restore_xinput_hook(safetyhook::InlineHook &hook) noexcept | |
| 812 | { | ||
| 813 | 237 | if (!hook) | |
| 814 | { | ||
| 815 | 22 | return PatchWitness::Original; | |
| 816 | } | ||
| 817 | 215 | if (!hook.enabled()) | |
| 818 | { | ||
| 819 | // A disabled backend has no target entry and writes nothing. A byte check at its former target reports | ||
| 820 | // Foreign for an unrelated writer and forces needless permanent retention. | ||
| 821 | 1 | return PatchWitness::Original; | |
| 822 | } | ||
| 823 | 214 | const PatchWitness before = xinput_teardown_witness(hook); | |
| 824 | 214 | if (!witness_permits_write(before)) | |
| 825 | { | ||
| 826 | ✗ | hook.reconcile_enabled(true); | |
| 827 | ✗ | return before; | |
| 828 | } | ||
| 829 | |||
| 830 | 214 | (void)try_xinput_backend_disable(hook); | |
| 831 | 214 | const PatchWitness after = xinput_patch_witness(hook); | |
| 832 | 214 | hook.reconcile_enabled(after != PatchWitness::Original); | |
| 833 | 214 | return after; | |
| 834 | } | ||
| 835 | |||
| 836 | /// Bounds the wait for closed bypass calls that a target restore released. | ||
| 837 | constexpr auto XINPUT_BYPASS_DRAIN_TIMEOUT = std::chrono::seconds{1}; | ||
| 838 | |||
| 839 | /** | ||
| 840 | * @brief Releases a hook after its target witnesses Original and its detour bodies drain. | ||
| 841 | * @details A closed bypass holds its route entry until the provider returns. Reset waits for those calls, so | ||
| 842 | * an ordinary completion does not retain the chain. After expiry, reset retains a counted chain. | ||
| 843 | * @return The retention reason, or null after a clean reset. | ||
| 844 | */ | ||
| 845 | [[nodiscard]] const char * | ||
| 846 | 232 | reset_inactive_xinput_hook(safetyhook::InlineHook &hook, std::atomic<XInputGetStateFn> &original) noexcept | |
| 847 | { | ||
| 848 | 232 | original.store(nullptr, std::memory_order_seq_cst); | |
| 849 | 232 | (void)drain_until_zero( | |
| 850 | 659 | [&hook]() noexcept { return hook.route_entries(); }, | |
| 851 | 232 | std::chrono::steady_clock::now() + XINPUT_BYPASS_DRAIN_TIMEOUT | |
| 852 | ); | ||
| 853 | 232 | hook.reset(); | |
| 854 | 232 | const bool retained = hook.route_retained(); | |
| 855 | 232 | if (retained) | |
| 856 | { | ||
| 857 | 6 | diagnostics::record_intentional_leak(diagnostics::LeakSubsystem::Input); | |
| 858 | } | ||
| 859 | 232 | return retained ? hook.route_retention_reason() : nullptr; | |
| 860 | } | ||
| 861 | |||
| 862 | /** | ||
| 863 | * @brief Reports retained chains after the interception lock releases. | ||
| 864 | */ | ||
| 865 | 200 | void emit_xinput_route_retention_logs(const char *primary, const char *ex) noexcept | |
| 866 | { | ||
| 867 | 200 | if (primary) | |
| 868 | { | ||
| 869 | 4 | (void)log().try_log( | |
| 870 | LogLevel::Warning, | ||
| 871 | "XInput route retention: XInputGetState retained its executable chain after reset. Reason: {}.", | ||
| 872 | primary | ||
| 873 | ); | ||
| 874 | } | ||
| 875 | 200 | if (ex) | |
| 876 | { | ||
| 877 | 2 | (void)log().try_log( | |
| 878 | LogLevel::Warning, | ||
| 879 | "XInput route retention: ordinal 100 retained its executable chain after reset. Reason: {}.", | ||
| 880 | ex | ||
| 881 | ); | ||
| 882 | } | ||
| 883 | 200 | } | |
| 884 | |||
| 885 | /** | ||
| 886 | * @brief Publishes a trampoline, then arms a hook whose prologue is not patched. | ||
| 887 | * @details Reuses the hook object and its trampoline, so nothing is created and no executable storage is | ||
| 888 | * freed or replaced. arm_xinput_hook's witnesses still gate the write and decide reachability. | ||
| 889 | */ | ||
| 890 | 422 | [[nodiscard]] XInputArmOutcome arm_created_xinput_hook( | |
| 891 | safetyhook::InlineHook &hook, | ||
| 892 | std::atomic<XInputGetStateFn> &original, | ||
| 893 | std::atomic<bool> &warning_latch, | ||
| 894 | std::string_view warning | ||
| 895 | ) noexcept | ||
| 896 | { | ||
| 897 | 422 | if (!hook) | |
| 898 | { | ||
| 899 | ✗ | return XInputArmOutcome::Uncommitted; | |
| 900 | } | ||
| 901 | 422 | if (hook.enabled()) | |
| 902 | { | ||
| 903 | ✗ | return XInputArmOutcome::Armed; | |
| 904 | } | ||
| 905 | 422 | const bool original_was_published = original.load(std::memory_order_seq_cst) != nullptr; | |
| 906 | 422 | original.store(hook.original<XInputGetStateFn>(), std::memory_order_seq_cst); | |
| 907 | 422 | return arm_xinput_hook(hook, original, original_was_published, warning_latch, warning); | |
| 908 | } | ||
| 909 | |||
| 910 | /// Reports whether rearm restores the forward path. | ||
| 911 | 25 | [[nodiscard]] bool rearm_xinput_hook( | |
| 912 | safetyhook::InlineHook &hook, | ||
| 913 | std::atomic<XInputGetStateFn> &original, | ||
| 914 | std::atomic<bool> &warning_latch, | ||
| 915 | std::string_view warning | ||
| 916 | ) noexcept | ||
| 917 | { | ||
| 918 | 25 | return arm_created_xinput_hook(hook, original, warning_latch, warning) == XInputArmOutcome::Armed; | |
| 919 | } | ||
| 920 | |||
| 921 | enum class XInputRetentionReason : std::uint8_t | ||
| 922 | { | ||
| 923 | InflightTimeout, | ||
| 924 | UnrestoredPatch, | ||
| 925 | UnprovedInstall | ||
| 926 | }; | ||
| 927 | |||
| 928 | struct XInputPublishedChains | ||
| 929 | { | ||
| 930 | bool primary{false}; | ||
| 931 | bool ex{false}; | ||
| 932 | }; | ||
| 933 | |||
| 934 | struct XInputRetentionLog | ||
| 935 | { | ||
| 936 | std::array<char, 256> attribution{}; | ||
| 937 | std::size_t attribution_length{0}; | ||
| 938 | XInputRetentionReason reason{XInputRetentionReason::InflightTimeout}; | ||
| 939 | bool pending{false}; | ||
| 940 | }; | ||
| 941 | |||
| 942 | /// Appends @p text at @p length and truncates at the buffer end. | ||
| 943 | 2009 | void append_text(std::span<char> buffer, std::size_t &length, std::string_view text) noexcept | |
| 944 | { | ||
| 945 | 2009 | const std::size_t room = buffer.size() - length; | |
| 946 | 2009 | const std::size_t count = text.size() < room ? text.size() : room; | |
| 947 | 2009 | std::memcpy(buffer.data() + length, text.data(), count); | |
| 948 | 2009 | length += count; | |
| 949 | 2009 | } | |
| 950 | |||
| 951 | /// Appends "0x" and the fixed 16-digit hex form of @p value. | ||
| 952 | 20 | void append_hex_address(std::span<char> buffer, std::size_t &length, std::uintptr_t value) noexcept | |
| 953 | { | ||
| 954 | 20 | append_text(buffer, length, "0x"); | |
| 955 | char digits[16]; | ||
| 956 | 340 | for (int i = 15; i >= 0; --i) | |
| 957 | { | ||
| 958 | 320 | digits[i] = "0123456789ABCDEF"[value & 0xFU]; | |
| 959 | 320 | value >>= 4U; | |
| 960 | } | ||
| 961 | 20 | append_text(buffer, length, std::string_view{digits, sizeof(digits)}); | |
| 962 | 20 | } | |
| 963 | |||
| 964 | /// Names the condition that forced the raw hook pair into permanent storage. | ||
| 965 | 10 | [[nodiscard]] constexpr std::string_view xinput_retention_message(XInputRetentionReason reason) noexcept | |
| 966 | { | ||
| 967 | 10 | switch (reason) | |
| 968 | { | ||
| 969 | 4 | case XInputRetentionReason::InflightTimeout: | |
| 970 | return "XInput interception: a game thread was still inside a detour at the quiesce deadline; " | ||
| 971 | 4 | "retained the hook trampolines instead of freeing them."; | |
| 972 | 5 | case XInputRetentionReason::UnrestoredPatch: | |
| 973 | return "XInput interception: target bytes could not be proved restored; retained the raw hook chain " | ||
| 974 | 5 | "instead of overwriting or freeing it."; | |
| 975 | 1 | case XInputRetentionReason::UnprovedInstall: | |
| 976 | 1 | break; | |
| 977 | } | ||
| 978 | return "XInput interception: the prologue reverted to its original bytes after the hook transaction " | ||
| 979 | 1 | "committed; retained the published trampoline instead of freeing it."; | |
| 980 | } | ||
| 981 | |||
| 982 | /** | ||
| 983 | * @brief Latches the canonical raw hook pair and keepalives as permanently retained. | ||
| 984 | * @note Requires s_intercept_mutex and a constructed process-lifetime cell. | ||
| 985 | * A supplied publication snapshot must be exact. | ||
| 986 | * Omit the snapshot only when a successful drain preceded the retention decision. | ||
| 987 | * Forwarding state then selects the published chains on its own. | ||
| 988 | */ | ||
| 989 | 10 | void retain_xinput_hooks( | |
| 990 | PatchWitness primary_witness, | ||
| 991 | PatchWitness ex_witness, | ||
| 992 | XInputRetentionReason reason, | ||
| 993 | XInputRetentionLog &deferred_log, | ||
| 994 | XInputPublishedChains published_chains = {} | ||
| 995 | ) noexcept | ||
| 996 | { | ||
| 997 | 10 | PermanentXInputHooks *const permanent = permanent_cell(); | |
| 998 | 10 | const bool primary_valid = static_cast<bool>(permanent->primary); | |
| 999 | 10 | const bool ex_valid = static_cast<bool>(permanent->ex); | |
| 1000 | 10 | const bool primary_forwarding_required = primary_valid && permanent->primary.enabled(); | |
| 1001 | 10 | const bool ex_forwarding_required = ex_valid && permanent->ex.enabled(); | |
| 1002 | 10 | permanent->primary.reconcile_enabled( | |
| 1003 | 10 | primary_forwarding_required && primary_witness != PatchWitness::Original | |
| 1004 | ); | ||
| 1005 | 10 | permanent->ex.reconcile_enabled(ex_forwarding_required && ex_witness != PatchWitness::Original); | |
| 1006 | |||
| 1007 | // Keep a chain published when the backend still forwards. Also keep it when pointer publication preceded | ||
| 1008 | // the retention decision. An admitted caller can still reach the detour's original-pointer load. | ||
| 1009 | 10 | const bool primary_chain_required = | |
| 1010 | 10 | primary_forwarding_required || (primary_valid && published_chains.primary); | |
| 1011 | 10 | const bool ex_chain_required = ex_forwarding_required || (ex_valid && published_chains.ex); | |
| 1012 | 20 | s_xinput_original.store( | |
| 1013 | 10 | primary_chain_required ? permanent->primary.original<XInputGetStateFn>() : nullptr, | |
| 1014 | std::memory_order_seq_cst | ||
| 1015 | ); | ||
| 1016 | 18 | s_xinput_ex_original.store( | |
| 1017 | 8 | ex_chain_required ? permanent->ex.original<XInputGetStateFn>() : nullptr, | |
| 1018 | std::memory_order_seq_cst | ||
| 1019 | ); | ||
| 1020 | 10 | s_xinput_permanent_detour.store(true, std::memory_order_release); | |
| 1021 | 10 | s_xinput_installed.store(false, std::memory_order_release); | |
| 1022 | 10 | DetourModKit::diagnostics::record_intentional_leak(DetourModKit::diagnostics::LeakSubsystem::Input); | |
| 1023 | |||
| 1024 | // Build the loader attribution under the lock. The caller emits it after the critical section. | ||
| 1025 | 10 | deferred_log.reason = reason; | |
| 1026 | 10 | append_text( | |
| 1027 | 10 | deferred_log.attribution, | |
| 1028 | 10 | deferred_log.attribution_length, | |
| 1029 | 10 | "XInput retention attribution: XInputGetState target " | |
| 1030 | ); | ||
| 1031 | 20 | append_hex_address( | |
| 1032 | 10 | deferred_log.attribution, | |
| 1033 | 10 | deferred_log.attribution_length, | |
| 1034 | 20 | permanent->primary ? permanent->primary.target_address() : std::uintptr_t{0} | |
| 1035 | ); | ||
| 1036 | 10 | append_text(deferred_log.attribution, deferred_log.attribution_length, " ("); | |
| 1037 | 20 | append_text( | |
| 1038 | 10 | deferred_log.attribution, | |
| 1039 | 10 | deferred_log.attribution_length, | |
| 1040 | witness_description(primary_witness) | ||
| 1041 | ); | ||
| 1042 | 10 | append_text(deferred_log.attribution, deferred_log.attribution_length, ")"); | |
| 1043 | 10 | if (permanent->ex) | |
| 1044 | { | ||
| 1045 | 10 | append_text(deferred_log.attribution, deferred_log.attribution_length, ". XInputGetStateEx target "); | |
| 1046 | 20 | append_hex_address( | |
| 1047 | 10 | deferred_log.attribution, | |
| 1048 | 10 | deferred_log.attribution_length, | |
| 1049 | permanent->ex.target_address() | ||
| 1050 | ); | ||
| 1051 | 10 | append_text(deferred_log.attribution, deferred_log.attribution_length, " ("); | |
| 1052 | 10 | append_text(deferred_log.attribution, deferred_log.attribution_length, witness_description(ex_witness)); | |
| 1053 | 10 | append_text(deferred_log.attribution, deferred_log.attribution_length, ")"); | |
| 1054 | } | ||
| 1055 | 10 | append_text(deferred_log.attribution, deferred_log.attribution_length, "."); | |
| 1056 | 10 | deferred_log.pending = true; | |
| 1057 | |||
| 1058 | 10 | s_xinput_pair_degraded.store(false, std::memory_order_release); | |
| 1059 | 10 | xinput_recovery_reset(); | |
| 1060 | 10 | s_xinput_enable_warned.store(false, std::memory_order_relaxed); | |
| 1061 | 10 | s_xinput_ex_enable_warned.store(false, std::memory_order_relaxed); | |
| 1062 | 10 | s_xinput_capacity_warned.store(false, std::memory_order_relaxed); | |
| 1063 | 10 | } | |
| 1064 | |||
| 1065 | /** @brief Emits a retention report after its caller releases s_intercept_mutex. */ | ||
| 1066 | 94 | void emit_xinput_retention_log(const XInputRetentionLog &deferred_log) noexcept | |
| 1067 | { | ||
| 1068 | 94 | if (!deferred_log.pending) | |
| 1069 | { | ||
| 1070 | 84 | return; | |
| 1071 | } | ||
| 1072 | 10 | const std::string_view attribution{deferred_log.attribution.data(), deferred_log.attribution_length}; | |
| 1073 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 1074 | 10 | if (const XInputRetentionAttributionSeam seam = | |
| 1075 | 10 | s_xinput_retention_attribution_seam.load(std::memory_order_acquire); | |
| 1076 | seam != nullptr) | ||
| 1077 | { | ||
| 1078 | 1 | seam(attribution); | |
| 1079 | } | ||
| 1080 | #endif | ||
| 1081 | 10 | (void)log().log_noexcept(LogLevel::Warning, xinput_retention_message(deferred_log.reason)); | |
| 1082 | 10 | (void)log().log_noexcept(LogLevel::Warning, attribution); | |
| 1083 | } | ||
| 1084 | |||
| 1085 | /** @brief Changes the controller and invalidates receipts for its prior binding under s_intercept_mutex. */ | ||
| 1086 | 2866 | void bind_xinput_user(int user_index) noexcept | |
| 1087 | { | ||
| 1088 | 2866 | if (s_bound_user_index.load(std::memory_order_relaxed) != user_index) | |
| 1089 | { | ||
| 1090 | s_xinput_hook_epoch.fetch_add(1, std::memory_order_seq_cst); | ||
| 1091 | 3 | s_xinput_observed_pair = false; | |
| 1092 | 3 | s_xinput_primary_observed = false; | |
| 1093 | 3 | s_xinput_ex_observed = false; | |
| 1094 | 3 | s_xinput_route_warned = false; | |
| 1095 | } | ||
| 1096 | s_bound_user_index.store(user_index, std::memory_order_relaxed); | ||
| 1097 | 2866 | } | |
| 1098 | |||
| 1099 | /** | ||
| 1100 | * @brief Requires s_intercept_mutex. Commits a complete XInput pair and activates suppression. | ||
| 1101 | * @details Both detours read s_xinput_installed, so suppression can never be live for one member of the | ||
| 1102 | * pair and not the other. | ||
| 1103 | */ | ||
| 1104 | 1443 | void publish_complete_xinput_pair(int user_index, std::uint64_t owner) noexcept | |
| 1105 | { | ||
| 1106 | 1443 | bind_xinput_user(user_index); | |
| 1107 | 1443 | xinput_recovery_reset(); | |
| 1108 | 1443 | s_xinput_pair_degraded.store(false, std::memory_order_release); | |
| 1109 | 1443 | s_xinput_installed.store(true, std::memory_order_release); | |
| 1110 | 1443 | publish_owner(owner); | |
| 1111 | 1443 | s_xinput_route_warned = false; | |
| 1112 | 1443 | } | |
| 1113 | |||
| 1114 | /** | ||
| 1115 | * @brief Requires s_intercept_mutex. Reads one pair member's target bytes and reports whether it still covers. | ||
| 1116 | * @param required Whether this entry point needs coverage for a complete pair. An absent or aliased | ||
| 1117 | * ordinal-100 export needs no second member. | ||
| 1118 | */ | ||
| 1119 | 2997 | [[nodiscard]] bool xinput_member_entry_witnessed(const safetyhook::InlineHook &hook, bool required) noexcept | |
| 1120 | { | ||
| 1121 | 2997 | if (!hook) | |
| 1122 | { | ||
| 1123 | 12 | return !required; | |
| 1124 | } | ||
| 1125 | 2985 | if (!hook.enabled()) | |
| 1126 | { | ||
| 1127 | 162 | return false; | |
| 1128 | } | ||
| 1129 | 2823 | return xinput_patch_witness(hook) == PatchWitness::OwnedPatch; | |
| 1130 | } | ||
| 1131 | |||
| 1132 | /** @brief Reports exact owned coverage and reconciles Original to disabled under s_intercept_mutex. */ | ||
| 1133 | 5710 | [[nodiscard]] bool xinput_member_covers_entry(safetyhook::InlineHook &hook, bool required) noexcept | |
| 1134 | { | ||
| 1135 | 5710 | if (!hook) | |
| 1136 | { | ||
| 1137 | 20 | return !required; | |
| 1138 | } | ||
| 1139 | 5690 | if (!hook.enabled()) | |
| 1140 | { | ||
| 1141 | 165 | return false; | |
| 1142 | } | ||
| 1143 | 5525 | const PatchWitness witness = xinput_patch_witness(hook); | |
| 1144 | 5525 | if (witness == PatchWitness::OwnedPatch) | |
| 1145 | { | ||
| 1146 | 2460 | return true; | |
| 1147 | } | ||
| 1148 | // Exact Original can be re-armed through the same object. Foreign and Indeterminate can still be a | ||
| 1149 | // newer chain through our trampoline, so preserve their enabled state and report only coverage loss. | ||
| 1150 | 3065 | if (witness == PatchWitness::Original) | |
| 1151 | { | ||
| 1152 | 6 | hook.reconcile_enabled(false); | |
| 1153 | } | ||
| 1154 | 3065 | return false; | |
| 1155 | } | ||
| 1156 | |||
| 1157 | /** | ||
| 1158 | * @brief Publishes exact-patch coverage under s_intercept_mutex. | ||
| 1159 | * @details Foreign entries require a separate unlocked route check. An unavailable member disarms both detours. | ||
| 1160 | * @return true when coverage was published. | ||
| 1161 | */ | ||
| 1162 | 1873 | [[nodiscard]] bool publish_xinput_pair_if_whole( | |
| 1163 | safetyhook::InlineHook &primary, | ||
| 1164 | safetyhook::InlineHook &ex, | ||
| 1165 | int user_index, | ||
| 1166 | std::uint64_t owner | ||
| 1167 | ) noexcept | ||
| 1168 | { | ||
| 1169 | 1873 | const bool primary_covered = xinput_member_covers_entry(primary, true); | |
| 1170 | 1873 | const bool ex_covered = xinput_member_covers_entry(ex, static_cast<bool>(ex)); | |
| 1171 | 1873 | if (primary_covered && ex_covered && xinput_consume_context_healthy()) | |
| 1172 | { | ||
| 1173 | 450 | s_xinput_observed_pair = false; | |
| 1174 | 450 | s_xinput_primary_observed = false; | |
| 1175 | 450 | s_xinput_ex_observed = false; | |
| 1176 | 450 | publish_complete_xinput_pair(user_index, owner); | |
| 1177 | 450 | return true; | |
| 1178 | } | ||
| 1179 | 1423 | bind_xinput_user(user_index); | |
| 1180 | 915 | if (!s_xinput_observed_pair || | |
| 1181 | 3278 | (xinput_patch_witness(primary) != PatchWitness::Foreign && !primary_covered) || | |
| 1182 | 1830 | (ex && xinput_patch_witness(ex) != PatchWitness::Foreign && !ex_covered)) | |
| 1183 | { | ||
| 1184 | 508 | s_xinput_installed.store(false, std::memory_order_release); | |
| 1185 | 508 | s_xinput_pair_degraded.store(true, std::memory_order_release); | |
| 1186 | } | ||
| 1187 | 1423 | publish_owner(owner); | |
| 1188 | 1423 | return false; | |
| 1189 | } | ||
| 1190 | |||
| 1191 | /** | ||
| 1192 | * @brief Requires s_intercept_mutex. Re-arms each member that lost entry-point coverage. | ||
| 1193 | * @details Each member uses its own hook object. Recovery of only the partner leaves the required export | ||
| 1194 | * permanently open. | ||
| 1195 | */ | ||
| 1196 | 982 | [[nodiscard]] bool rearm_xinput_pair(safetyhook::InlineHook &primary, safetyhook::InlineHook &ex) noexcept | |
| 1197 | { | ||
| 1198 | 982 | bool primary_covered = xinput_member_covers_entry(primary, true); | |
| 1199 | 982 | if (!primary_covered && !primary.enabled()) | |
| 1200 | { | ||
| 1201 | 8 | (void)rearm_xinput_hook( | |
| 1202 | primary, | ||
| 1203 | s_xinput_original, | ||
| 1204 | s_xinput_enable_warned, | ||
| 1205 | 8 | "InputIntercept: the XInputGetState re-arm did not complete cleanly, so XInput " | |
| 1206 | "coverage stays degraded and both entries pass through." | ||
| 1207 | ); | ||
| 1208 | 8 | primary_covered = xinput_member_entry_witnessed(primary, true); | |
| 1209 | } | ||
| 1210 | 982 | bool ex_covered = xinput_member_covers_entry(ex, static_cast<bool>(ex)); | |
| 1211 | 982 | if (!ex_covered && !ex.enabled()) | |
| 1212 | { | ||
| 1213 | 13 | (void)rearm_xinput_hook( | |
| 1214 | ex, | ||
| 1215 | s_xinput_ex_original, | ||
| 1216 | s_xinput_ex_enable_warned, | ||
| 1217 | 13 | "InputIntercept: the XInputGetStateEx re-arm did not complete cleanly, so " | |
| 1218 | "XInput coverage stays degraded and both entries pass through." | ||
| 1219 | ); | ||
| 1220 | 13 | ex_covered = xinput_member_entry_witnessed(ex, static_cast<bool>(ex)); | |
| 1221 | } | ||
| 1222 | 982 | return primary_covered && ex_covered; | |
| 1223 | } | ||
| 1224 | |||
| 1225 | /** | ||
| 1226 | * @brief Requires s_intercept_mutex. Re-witnesses a pair and drives deadline-gated recovery of absent members. | ||
| 1227 | * @details One routine handles per-cycle health, live-pair recovery, and retained-pair recovery. It detects | ||
| 1228 | * loss of pair integrity after publication. Every publication uses the final pair witness. | ||
| 1229 | * @return true when complete coverage is published for @p owner. | ||
| 1230 | */ | ||
| 1231 | 1660 | [[nodiscard]] bool maintain_xinput_pair( | |
| 1232 | safetyhook::InlineHook &primary, | ||
| 1233 | safetyhook::InlineHook &ex, | ||
| 1234 | const void *target_module, | ||
| 1235 | int user_index, | ||
| 1236 | std::uint64_t owner | ||
| 1237 | ) noexcept | ||
| 1238 | { | ||
| 1239 | 1660 | if (publish_xinput_pair_if_whole(primary, ex, user_index, owner)) | |
| 1240 | { | ||
| 1241 | 286 | return true; | |
| 1242 | } | ||
| 1243 | |||
| 1244 | const XInputRecoveryEvidence evidence{ | ||
| 1245 | target_module, | ||
| 1246 | owner, | ||
| 1247 | 1374 | xinput_member_entry_witnessed(primary, true), | |
| 1248 | 1374 | xinput_member_entry_witnessed(ex, static_cast<bool>(ex)), | |
| 1249 | 1374 | witness_permits_write(xinput_patch_witness(primary)), | |
| 1250 | 1374 | witness_permits_write(xinput_patch_witness(ex)) | |
| 1251 | 4122 | }; | |
| 1252 | 1374 | if (!xinput_recovery_due(evidence)) | |
| 1253 | { | ||
| 1254 | 392 | return false; | |
| 1255 | } | ||
| 1256 | 989 | if (record_xinput_recovery_attempt(rearm_xinput_pair(primary, ex)) && | |
| 1257 | 7 | publish_xinput_pair_if_whole(primary, ex, user_index, owner)) | |
| 1258 | { | ||
| 1259 | 7 | return true; | |
| 1260 | } | ||
| 1261 | 975 | xinput_recovery_deferred(); | |
| 1262 | 975 | return false; | |
| 1263 | } | ||
| 1264 | |||
| 1265 | /** | ||
| 1266 | * @brief Marks a game thread as active inside an XInput detour body. | ||
| 1267 | * @details This counter and the published trampoline pointer form a Dekker-style pair with the retire store in | ||
| 1268 | * retire_published_xinput_chains() and the drain load in quiesce_xinput_routes_until_deadline(). Both | ||
| 1269 | * sides use store then load. Acquire and release do not forbid this StoreLoad order change. The | ||
| 1270 | * increment, trampoline load, retire store, and drain load use seq_cst. The decrement stays release | ||
| 1271 | * and does not belong to the StoreLoad pair. | ||
| 1272 | */ | ||
| 1273 | struct InflightGuard | ||
| 1274 | { | ||
| 1275 | 16827 | InflightGuard() noexcept { s_xinput_inflight.fetch_add(1, std::memory_order_seq_cst); } | |
| 1276 | 16685 | ~InflightGuard() noexcept { s_xinput_inflight.fetch_sub(1, std::memory_order_release); } | |
| 1277 | InflightGuard(const InflightGuard &) = delete; | ||
| 1278 | InflightGuard &operator=(const InflightGuard &) = delete; | ||
| 1279 | InflightGuard(InflightGuard &&) = delete; | ||
| 1280 | InflightGuard &operator=(InflightGuard &&) = delete; | ||
| 1281 | }; | ||
| 1282 | |||
| 1283 | // Each detour-side consume rule occupies one atomic word, so readers never see a torn rule. A seqlock protects | ||
| 1284 | // the array and count. An even value is stable, and an odd value marks an update. Game XInput threads read | ||
| 1285 | // snapshots without a lock. The two writers, clear_data_plane_locked() and publish_gamepad_consume_rules(), | ||
| 1286 | // serialize through s_data_plane_mutex. InputPoller publication also holds InputPoller::m_bindings_rw_mutex. | ||
| 1287 | std::array<std::atomic<uint64_t>, MAX_GAMEPAD_CONSUME_RULES> s_consume_rules{}; | ||
| 1288 | std::atomic<uint32_t> s_consume_rule_count{0}; | ||
| 1289 | std::atomic<uint32_t> s_consume_rules_seq{0}; | ||
| 1290 | |||
| 1291 | // The focus gate disables same-frame rules while their published table and deadline survive focus changes. | ||
| 1292 | std::atomic<bool> s_rule_suppress_enabled{false}; | ||
| 1293 | |||
| 1294 | /** | ||
| 1295 | * @brief Packs a rule into one word: modifier (bits 0-15), forbidden (16-31), trigger (32-47). | ||
| 1296 | * @details Three 16-bit masks fit a uint64 with room to spare, so a rule is published and read as a single | ||
| 1297 | * atomic store/load. | ||
| 1298 | */ | ||
| 1299 | 271 | constexpr uint64_t pack_consume_rule(const GamepadConsumeRule &rule) noexcept | |
| 1300 | { | ||
| 1301 | 271 | return static_cast<uint64_t>(rule.modifier_mask) | (static_cast<uint64_t>(rule.forbidden_mask) << 16) | | |
| 1302 | 271 | (static_cast<uint64_t>(rule.trigger_mask) << 32); | |
| 1303 | } | ||
| 1304 | |||
| 1305 | /// Unpacks a consume rule. | ||
| 1306 | 463 | constexpr GamepadConsumeRule unpack_consume_rule(uint64_t packed) noexcept | |
| 1307 | { | ||
| 1308 | return GamepadConsumeRule{ | ||
| 1309 | static_cast<uint16_t>(packed & 0xFFFFu), | ||
| 1310 | 463 | static_cast<uint16_t>((packed >> 16) & 0xFFFFu), | |
| 1311 | 463 | static_cast<uint16_t>((packed >> 32) & 0xFFFFu) | |
| 1312 | 463 | }; | |
| 1313 | } | ||
| 1314 | |||
| 1315 | std::array<std::atomic<std::uint64_t>, 4> s_wheel_count{ | ||
| 1316 | wheel_count_slot(1, 0), | ||
| 1317 | wheel_count_slot(1, 0), | ||
| 1318 | wheel_count_slot(1, 0), | ||
| 1319 | wheel_count_slot(1, 0) | ||
| 1320 | }; | ||
| 1321 | // Index zero stores vertical distance, and index one stores horizontal distance. Each axis has one signed | ||
| 1322 | // remainder because a reversal cancels sub-notch distance while the axes remain independent. | ||
| 1323 | std::array<std::atomic<std::uint64_t>, 2> s_wheel_remainder{ | ||
| 1324 | wheel_remainder_slot(1, false, 0), | ||
| 1325 | wheel_remainder_slot(1, false, 0) | ||
| 1326 | }; | ||
| 1327 | // The per-direction wheel-swallow mask uses WheelDirection bits and pairs with a TTL. Each poll cycle refreshes | ||
| 1328 | // it, so a stalled poll thread stops the swallow action. "Ctrl+WheelUp" must not eat bare WheelDown or WheelUp. | ||
| 1329 | std::atomic<uint8_t> s_wheel_consume_mask{0}; | ||
| 1330 | std::atomic<uint64_t> s_wheel_consume_deadline_ms{0}; | ||
| 1331 | // When set, wheel count admission and consume finalization also require process foreground. Published by the | ||
| 1332 | // poll loop together with the consume mask, and cleared on revocation. | ||
| 1333 | std::atomic<bool> s_wheel_require_focus{false}; | ||
| 1334 | |||
| 1335 | /// Retags the wheel counters and remainders under @p wheel_epoch and clears the consume mask. | ||
| 1336 | 333 | void reset_wheel_data_plane(std::uint64_t wheel_epoch) noexcept | |
| 1337 | { | ||
| 1338 | s_wheel_consume_mask.store(0, std::memory_order_release); | ||
| 1339 | 1665 | for (auto &count : s_wheel_count) | |
| 1340 | { | ||
| 1341 | 1332 | count.store(wheel_count_slot(wheel_epoch, 0), std::memory_order_relaxed); | |
| 1342 | } | ||
| 1343 | 999 | for (auto &remainder : s_wheel_remainder) | |
| 1344 | { | ||
| 1345 | 666 | remainder.store(wheel_remainder_slot(wheel_epoch, false, 0), std::memory_order_relaxed); | |
| 1346 | } | ||
| 1347 | 333 | } | |
| 1348 | |||
| 1349 | 323 | void clear_data_plane_locked(std::uint64_t wheel_epoch) noexcept | |
| 1350 | { | ||
| 1351 | // Single-atomic disarms occur first, so detour suppression stops before the multi-step rule update begins. | ||
| 1352 | s_suppress_mask.store(0, std::memory_order_release); | ||
| 1353 | 323 | s_rule_suppress_enabled.store(false, std::memory_order_relaxed); | |
| 1354 | // A revoked layer carries no focus requirement, so the next owner starts from the default gate. | ||
| 1355 | 323 | s_wheel_require_focus.store(false, std::memory_order_relaxed); | |
| 1356 | 323 | reset_wheel_data_plane(wheel_epoch); | |
| 1357 | |||
| 1358 | // Safe only because every writer now holds this lock, so the bracket cannot interleave with a binding | ||
| 1359 | // mutation. The clear exists so a later owner cannot inherit the previous owner's chords. | ||
| 1360 | 323 | const uint32_t seq = s_consume_rules_seq.load(std::memory_order_relaxed); | |
| 1361 | 323 | s_consume_rules_seq.store(seq + 1, std::memory_order_relaxed); | |
| 1362 | std::atomic_thread_fence(std::memory_order_release); | ||
| 1363 | s_consume_rule_count.store(0, std::memory_order_relaxed); | ||
| 1364 | 323 | s_consume_rules_seq.store(seq + 2, std::memory_order_release); | |
| 1365 | 323 | } | |
| 1366 | |||
| 1367 | // Local message-hook wheel-capture source (the single-DLL default). It observes wheel messages through a | ||
| 1368 | // thread-scoped WH_GETMESSAGE hook and feeds the shared fold and drain machinery. The module reference is | ||
| 1369 | // permanent because a selected callback can run after UnhookWindowsHookEx. | ||
| 1370 | // | ||
| 1371 | // Route identity: the mounted target thread's numeric id, its SYNCHRONIZE handle, a mount generation, and a | ||
| 1372 | // typed route state. Readiness derives from the live handle and the mounted id, never from a sticky flag. | ||
| 1373 | // The handle, generation, and state transitions are guarded by s_intercept_mutex; the id and state atomics | ||
| 1374 | // give the hook callback and cheap queries a lock-free read. | ||
| 1375 | std::atomic<HHOOK> s_msg_hook{nullptr}; | ||
| 1376 | std::atomic<bool> s_msg_hook_ref_taken{false}; | ||
| 1377 | std::atomic<DWORD> s_msg_hook_thread_id{0}; | ||
| 1378 | HANDLE s_msg_hook_thread = nullptr; | ||
| 1379 | std::uint64_t s_msg_hook_mount_generation = 0; | ||
| 1380 | std::atomic<std::uint8_t> s_msg_hook_route_state{static_cast<std::uint8_t>(WheelRouteState::TargetWait)}; | ||
| 1381 | |||
| 1382 | 3006 | bool wheel_capture_armable_locked() noexcept | |
| 1383 | { | ||
| 1384 | 3044 | return s_msg_hook.load(std::memory_order_relaxed) == nullptr || | |
| 1385 | 38 | s_msg_hook_route_state.load(std::memory_order_relaxed) == | |
| 1386 | 3006 | static_cast<std::uint8_t>(WheelRouteState::Ready); | |
| 1387 | } | ||
| 1388 | |||
| 1389 | // Counts callback frames inside a wheel admission phase (count admission or consume finalization). The | ||
| 1390 | // control plane drains it, bounded, after an epoch advance so no admitted decision reaches a successor | ||
| 1391 | // route. A frame parked inside a lower hook holds no phase. | ||
| 1392 | std::atomic<std::uint32_t> s_wheel_admitted_phases{0}; | ||
| 1393 | |||
| 1394 | /// Bounds every control-plane wait for wheel admitted phases. | ||
| 1395 | constexpr std::uint64_t WHEEL_DRAIN_TIMEOUT_MS = 2000; | ||
| 1396 | |||
| 1397 | /// Takes a counted reference for the module at @p address and books it as an XInputTarget pin. | ||
| 1398 | 406 | [[nodiscard]] HMODULE acquire_module_ref_containing_address(const void *address) noexcept | |
| 1399 | { | ||
| 1400 | 406 | if (address == nullptr) | |
| 1401 | { | ||
| 1402 | ✗ | return nullptr; | |
| 1403 | } | ||
| 1404 | |||
| 1405 | 406 | HMODULE module = nullptr; | |
| 1406 | 406 | if (!GetModuleHandleExW( | |
| 1407 | GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, | ||
| 1408 | reinterpret_cast<LPCWSTR>(address), | ||
| 1409 | &module | ||
| 1410 | )) | ||
| 1411 | { | ||
| 1412 | ✗ | return nullptr; | |
| 1413 | } | ||
| 1414 | 406 | module_pin_observability::note_acquired(diagnostics::ModulePinReason::XInputTarget); | |
| 1415 | 406 | return module; | |
| 1416 | } | ||
| 1417 | |||
| 1418 | /** | ||
| 1419 | * @brief Adds @p notches to an epoch-tagged, per-direction wheel counter with saturation. | ||
| 1420 | * @details A revocation retags the slot. A writer with the retired epoch fails and cannot publish into the | ||
| 1421 | * successor backlog. Saturation bounds idle accretion after the last binding is gone. | ||
| 1422 | * @return true when the slot records the notches or is already saturated. Returns false when @p epoch is | ||
| 1423 | * retired. | ||
| 1424 | */ | ||
| 1425 | [[nodiscard]] bool | ||
| 1426 | 1200 | bump_wheel_notch(std::atomic<std::uint64_t> &slot, std::uint64_t epoch, std::uint64_t notches = 1) noexcept | |
| 1427 | { | ||
| 1428 | 1200 | std::uint64_t current = slot.load(std::memory_order_relaxed); | |
| 1429 | 1200 | while (wheel_slot_epoch(current) == epoch) | |
| 1430 | { | ||
| 1431 | 1200 | const std::uint64_t count = current & WHEEL_COUNT_MASK; | |
| 1432 | 1200 | if (count >= MAX_WHEEL_NOTCHES) | |
| 1433 | { | ||
| 1434 | 128 | return true; | |
| 1435 | } | ||
| 1436 | const std::uint64_t next = | ||
| 1437 | 1072 | std::min<std::uint64_t>(count + notches, static_cast<std::uint64_t>(MAX_WHEEL_NOTCHES)); | |
| 1438 | 2144 | if (slot.compare_exchange_weak( | |
| 1439 | current, | ||
| 1440 | wheel_count_slot(epoch, next), | ||
| 1441 | std::memory_order_relaxed, | ||
| 1442 | std::memory_order_relaxed | ||
| 1443 | )) | ||
| 1444 | { | ||
| 1445 | 1072 | return true; | |
| 1446 | } | ||
| 1447 | } | ||
| 1448 | ✗ | return false; | |
| 1449 | } | ||
| 1450 | |||
| 1451 | /** | ||
| 1452 | * @brief Folds one wheel message's signed delta into an axis remainder tagged by (epoch, owned). | ||
| 1453 | * @details A stored tag from another (epoch, owned) state contributes nothing, so ownership flips and epoch | ||
| 1454 | * advances restart accumulation. A retired-epoch slot refuses the fold entirely. | ||
| 1455 | * @return The admission verdict and, when admitted, the signed whole-notch count of the fold. | ||
| 1456 | */ | ||
| 1457 | struct WheelFold | ||
| 1458 | { | ||
| 1459 | bool admitted; | ||
| 1460 | int notches; | ||
| 1461 | }; | ||
| 1462 | 1213 | [[nodiscard]] WheelFold accumulate_wheel_remainder( | |
| 1463 | std::atomic<std::uint64_t> &slot, | ||
| 1464 | std::uint64_t epoch, | ||
| 1465 | bool owned, | ||
| 1466 | int delta | ||
| 1467 | ) noexcept | ||
| 1468 | { | ||
| 1469 | 1213 | std::uint64_t current = slot.load(std::memory_order_relaxed); | |
| 1470 | 1213 | while ((current >> WHEEL_REMAINDER_EPOCH_SHIFT) == epoch) | |
| 1471 | { | ||
| 1472 | 1213 | int prior = 0; | |
| 1473 | 1213 | if (((current & WHEEL_REMAINDER_OWNED_BIT) != 0) == owned) | |
| 1474 | { | ||
| 1475 | 1199 | prior = static_cast<int>(current & WHEEL_REMAINDER_VALUE_MASK) - WHEEL_REMAINDER_BIAS; | |
| 1476 | } | ||
| 1477 | // delta is a signed short and |prior| < WHEEL_DELTA, so the total cannot overflow int. The quotient | ||
| 1478 | // truncates toward zero and keeps the remainder sign equal to the total sign. | ||
| 1479 | 1213 | const int total = prior + delta; | |
| 1480 | 1213 | const int notches = total / WHEEL_DELTA; | |
| 1481 | 1213 | const int remainder = total % WHEEL_DELTA; | |
| 1482 | 2426 | if (slot.compare_exchange_weak( | |
| 1483 | current, | ||
| 1484 | wheel_remainder_slot(epoch, owned, remainder), | ||
| 1485 | std::memory_order_relaxed, | ||
| 1486 | std::memory_order_relaxed | ||
| 1487 | )) | ||
| 1488 | { | ||
| 1489 | 1213 | return WheelFold{true, notches}; | |
| 1490 | } | ||
| 1491 | } | ||
| 1492 | ✗ | return WheelFold{false, 0}; | |
| 1493 | } | ||
| 1494 | |||
| 1495 | /** | ||
| 1496 | * @brief Reports whether the detour swallows a wheel message of the given direction this instant. | ||
| 1497 | * @details The acquire load of the mask orders the relaxed deadline read (publish_wheel_consume writes the | ||
| 1498 | * deadline first). A lapsed deadline forwards, so the game is never latched out of its wheel. | ||
| 1499 | * @param direction_bit One WheelDirection bit for the message direction. | ||
| 1500 | */ | ||
| 1501 | 1228 | bool wheel_direction_consumed(uint8_t direction_bit) noexcept | |
| 1502 | { | ||
| 1503 | 1228 | if ((s_wheel_consume_mask.load(std::memory_order_acquire) & direction_bit) == 0) | |
| 1504 | { | ||
| 1505 | 1197 | return false; | |
| 1506 | } | ||
| 1507 | 62 | return GetTickCount64() < s_wheel_consume_deadline_ms.load(std::memory_order_relaxed); | |
| 1508 | } | ||
| 1509 | |||
| 1510 | /// Brackets one wheel admission phase. Allocation-free, nonblocking, and free of DMK locks. | ||
| 1511 | class WheelPhaseGuard | ||
| 1512 | { | ||
| 1513 | public: | ||
| 1514 | 1233 | WheelPhaseGuard() noexcept { s_wheel_admitted_phases.fetch_add(1, std::memory_order_seq_cst); } | |
| 1515 | 1233 | ~WheelPhaseGuard() noexcept { s_wheel_admitted_phases.fetch_sub(1, std::memory_order_seq_cst); } | |
| 1516 | WheelPhaseGuard(const WheelPhaseGuard &) = delete; | ||
| 1517 | WheelPhaseGuard &operator=(const WheelPhaseGuard &) = delete; | ||
| 1518 | }; | ||
| 1519 | |||
| 1520 | /// Reports whether this process owns the foreground window. | ||
| 1521 | 5 | [[nodiscard]] bool process_owns_foreground() noexcept | |
| 1522 | { | ||
| 1523 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 1524 | 5 | if (const std::int32_t override_value = s_wheel_process_focus_override.load(std::memory_order_acquire); | |
| 1525 | override_value >= 0) | ||
| 1526 | { | ||
| 1527 | 5 | return override_value != 0; | |
| 1528 | } | ||
| 1529 | #endif | ||
| 1530 | ✗ | const HWND foreground = GetForegroundWindow(); | |
| 1531 | ✗ | if (foreground == nullptr) | |
| 1532 | { | ||
| 1533 | ✗ | return false; | |
| 1534 | } | ||
| 1535 | ✗ | DWORD pid = 0; | |
| 1536 | ✗ | GetWindowThreadProcessId(foreground, &pid); | |
| 1537 | ✗ | return pid == GetCurrentProcessId(); | |
| 1538 | } | ||
| 1539 | |||
| 1540 | /// Reports whether the published focus gate admits wheel counting and consume right now. | ||
| 1541 | 1230 | [[nodiscard]] bool wheel_focus_admits() noexcept | |
| 1542 | { | ||
| 1543 | 1230 | return !s_wheel_require_focus.load(std::memory_order_relaxed) || process_owns_foreground(); | |
| 1544 | } | ||
| 1545 | |||
| 1546 | /** | ||
| 1547 | * @brief Waits, bounded, for every wheel admitted phase to leave. | ||
| 1548 | * @details Runs only on the control plane, after an epoch advance already invalidated the admissions it | ||
| 1549 | * waits on. A parked lower-hook frame holds no phase, so the wait covers only the short | ||
| 1550 | * allocation-free admission windows. | ||
| 1551 | */ | ||
| 1552 | 32 | [[nodiscard]] bool drain_wheel_admitted_phases() noexcept | |
| 1553 | { | ||
| 1554 | 32 | std::uint64_t timeout_ms = WHEEL_DRAIN_TIMEOUT_MS; | |
| 1555 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 1556 | 32 | if (const std::uint64_t override_ms = s_wheel_drain_timeout_override_ms.load(std::memory_order_acquire); | |
| 1557 | override_ms != 0) | ||
| 1558 | { | ||
| 1559 | 2 | timeout_ms = override_ms; | |
| 1560 | } | ||
| 1561 | #endif | ||
| 1562 | 32 | const std::uint64_t deadline_ms = GetTickCount64() + timeout_ms; | |
| 1563 | 292211 | while (s_wheel_admitted_phases.load(std::memory_order_seq_cst) != 0) | |
| 1564 | { | ||
| 1565 | 292148 | if (GetTickCount64() >= deadline_ms) | |
| 1566 | { | ||
| 1567 | 1 | return false; | |
| 1568 | } | ||
| 1569 | 292147 | Sleep(0); | |
| 1570 | } | ||
| 1571 | 31 | return true; | |
| 1572 | } | ||
| 1573 | |||
| 1574 | /// Returns the WheelDirection bit for one signed delta on one axis. | ||
| 1575 | 1228 | [[nodiscard]] uint8_t wheel_delta_direction_bit(bool horizontal, int delta) noexcept | |
| 1576 | { | ||
| 1577 | // A positive vertical delta scrolls up, and a negative delta scrolls down. A positive horizontal delta | ||
| 1578 | // tilts right, and a negative delta tilts left. | ||
| 1579 | 2448 | const WheelDirection direction = horizontal ? (delta > 0 ? WheelDirection::Right : WheelDirection::Left) | |
| 1580 | 1220 | : (delta > 0 ? WheelDirection::Up : WheelDirection::Down); | |
| 1581 | 1228 | return wheel_direction_bit(direction); | |
| 1582 | } | ||
| 1583 | |||
| 1584 | /** | ||
| 1585 | * @brief Wheel count admission: accumulates sub-notch distance, publishes whole notches, and snapshots the | ||
| 1586 | * consume intent, all before CallNextHookEx and without a message mutation. | ||
| 1587 | * @details GET_WHEEL_DELTA_WPARAM is signed and need not be a WHEEL_DELTA multiple. The axis remainder folds | ||
| 1588 | * fragments into `abs(total) / WHEEL_DELTA` notches. A reversal cancels accumulated distance. The | ||
| 1589 | * (epoch, owned) tag separates owned and unowned fragments (WheelDeltaTest.*). The admitted phase | ||
| 1590 | * is counted so the control plane can drain it. | ||
| 1591 | * @param horizontal When true, selects Right/Left. When false, selects Up/Down. | ||
| 1592 | * @param delta Signed wheel delta from the message. | ||
| 1593 | * @param capture_state Atomic capture state sampled when the callback frame began. | ||
| 1594 | * @return true when the frame snapshots consume intent for this message's direction. | ||
| 1595 | */ | ||
| 1596 | 1219 | [[nodiscard]] bool wheel_count_admission(bool horizontal, int delta, std::uint64_t capture_state) noexcept | |
| 1597 | { | ||
| 1598 | 1219 | if (delta == 0 || (capture_state & WHEEL_CAPTURE_ENABLED) == 0) | |
| 1599 | { | ||
| 1600 | 2 | return false; | |
| 1601 | } | ||
| 1602 | 1217 | const WheelPhaseGuard phase; | |
| 1603 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 1604 | 1217 | if (const WheelCaptureEntrySeam seam = s_wheel_capture_entry_seam.load(std::memory_order_acquire); | |
| 1605 | seam != nullptr) | ||
| 1606 | { | ||
| 1607 | 3 | seam(); | |
| 1608 | } | ||
| 1609 | #endif | ||
| 1610 | // Re-read the capture state inside the counted phase, so an epoch advanced by a concurrent control | ||
| 1611 | // transaction refuses this admission. | ||
| 1612 | 1217 | const std::uint64_t epoch = wheel_capture_epoch(capture_state); | |
| 1613 | 1217 | const std::uint64_t recheck = s_wheel_capture_state.load(std::memory_order_seq_cst); | |
| 1614 | 2431 | if (wheel_capture_epoch(recheck) != epoch || (recheck & WHEEL_CAPTURE_ENABLED) == 0 || | |
| 1615 | 1214 | !wheel_focus_admits()) | |
| 1616 | { | ||
| 1617 | 4 | return false; | |
| 1618 | } | ||
| 1619 | 1213 | const bool owned = wheel_direction_consumed(wheel_delta_direction_bit(horizontal, delta)); | |
| 1620 | const WheelFold fold = | ||
| 1621 | 1213 | accumulate_wheel_remainder(s_wheel_remainder[horizontal ? 1 : 0], epoch, owned, delta); | |
| 1622 | 1213 | if (!fold.admitted) | |
| 1623 | { | ||
| 1624 | ✗ | return false; | |
| 1625 | } | ||
| 1626 | 1213 | if (fold.notches > 0) | |
| 1627 | { | ||
| 1628 | 1189 | const std::size_t positive_dir = horizontal ? 3u : 0u; | |
| 1629 | 1189 | (void)bump_wheel_notch(s_wheel_count[positive_dir], epoch, static_cast<std::uint64_t>(fold.notches)); | |
| 1630 | } | ||
| 1631 | 24 | else if (fold.notches < 0) | |
| 1632 | { | ||
| 1633 | 11 | const std::size_t negative_dir = horizontal ? 2u : 1u; | |
| 1634 | 11 | (void)bump_wheel_notch(s_wheel_count[negative_dir], epoch, static_cast<std::uint64_t>(-fold.notches)); | |
| 1635 | } | ||
| 1636 | 1213 | return owned; | |
| 1637 | 1217 | } | |
| 1638 | |||
| 1639 | /** | ||
| 1640 | * @brief Wheel consume finalization: revalidates a saved consume intent after CallNextHookEx returned. | ||
| 1641 | * @details The WM_NULL write is permitted only while the entry epoch, the consume mask, its TTL, and the | ||
| 1642 | * focus gate all remain current. The admitted phase is counted so the control plane can drain it. | ||
| 1643 | * @return true when the caller must rewrite the message to WM_NULL. | ||
| 1644 | */ | ||
| 1645 | 16 | [[nodiscard]] bool wheel_consume_finalization(bool horizontal, int delta, std::uint64_t capture_state) noexcept | |
| 1646 | { | ||
| 1647 | 16 | const WheelPhaseGuard phase; | |
| 1648 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 1649 | 16 | if (const WheelFinalizeEntrySeam seam = s_wheel_finalize_entry_seam.load(std::memory_order_acquire); | |
| 1650 | seam != nullptr) | ||
| 1651 | { | ||
| 1652 | 1 | seam(); | |
| 1653 | } | ||
| 1654 | #endif | ||
| 1655 | 16 | const std::uint64_t epoch = wheel_capture_epoch(capture_state); | |
| 1656 | 16 | const std::uint64_t recheck = s_wheel_capture_state.load(std::memory_order_seq_cst); | |
| 1657 | 32 | if (wheel_capture_epoch(recheck) != epoch || (recheck & WHEEL_CAPTURE_ENABLED) == 0 || | |
| 1658 | 16 | !wheel_focus_admits()) | |
| 1659 | { | ||
| 1660 | 1 | return false; | |
| 1661 | } | ||
| 1662 | 15 | return wheel_direction_consumed(wheel_delta_direction_bit(horizontal, delta)); | |
| 1663 | 16 | } | |
| 1664 | |||
| 1665 | /** | ||
| 1666 | * @brief Computes the returned buttons from the current state and this copy's consume rules. | ||
| 1667 | * @details dwPacketNumber and the success return stay untouched, so the game sees a connected controller with | ||
| 1668 | * packet progress. The cleared bits combine the reactive mask with the consume rules. A TTL guard | ||
| 1669 | * drops all suppression after poll refreshes stop. | ||
| 1670 | */ | ||
| 1671 | [[nodiscard]] WORD | ||
| 1672 | 506 | suppressed_xinput_buttons(const XINPUT_STATE *state, DWORD user_index, WORD original_buttons) noexcept | |
| 1673 | { | ||
| 1674 | // A retained primary route can remain physically reachable before recovery completes for its Ex partner. | ||
| 1675 | // Keep both routes fail-open until the complete logical installation is published. | ||
| 1676 | 1012 | if (is_xinput_raw_call() || !xinput_consume_context_healthy() || | |
| 1677 | 1012 | !s_xinput_installed.load(std::memory_order_acquire) || state == nullptr) | |
| 1678 | { | ||
| 1679 | 24 | return state != nullptr ? state->Gamepad.wButtons : 0; | |
| 1680 | } | ||
| 1681 | 964 | if (user_index > 3 || static_cast<int>(user_index) != s_bound_user_index.load(std::memory_order_relaxed)) | |
| 1682 | { | ||
| 1683 | 154 | return state->Gamepad.wButtons; | |
| 1684 | } | ||
| 1685 | // The acquire load of the mask orders the relaxed deadline read below (the writer stores the deadline | ||
| 1686 | // first), even when the mask reads as 0. | ||
| 1687 | 328 | const uint16_t reactive = s_suppress_mask.load(std::memory_order_acquire); | |
| 1688 | |||
| 1689 | // Original-button context preserves modifiers that another compatible copy already consumed. | ||
| 1690 | 328 | const uint16_t rule_mask = s_rule_suppress_enabled.load(std::memory_order_relaxed) | |
| 1691 | 232 | ? evaluate_published_consume_rules(original_buttons) | |
| 1692 | 328 | : 0; | |
| 1693 | 328 | const uint16_t mask = static_cast<uint16_t>(reactive | rule_mask); | |
| 1694 | 328 | if (mask == 0) | |
| 1695 | { | ||
| 1696 | 22 | return state->Gamepad.wButtons; | |
| 1697 | } | ||
| 1698 | // A stalled poll thread lets the deadline lapse and stops all suppression. The game regains its input | ||
| 1699 | // instead of a permanent latch. | ||
| 1700 | 612 | if (GetTickCount64() >= s_suppress_deadline_ms.load(std::memory_order_relaxed)) | |
| 1701 | { | ||
| 1702 | ✗ | return state->Gamepad.wButtons; | |
| 1703 | } | ||
| 1704 | 306 | return static_cast<WORD>(state->Gamepad.wButtons & static_cast<WORD>(~mask)); | |
| 1705 | } | ||
| 1706 | |||
| 1707 | /** @brief Applies this copy's mask only after a successful original-button context restore. */ | ||
| 1708 | 16818 | void finish_xinput_consume_scope( | |
| 1709 | std::optional<XInputConsumeScope> &scope, | ||
| 1710 | XINPUT_STATE *state, | ||
| 1711 | DWORD user_index, | ||
| 1712 | DWORD result | ||
| 1713 | ) noexcept | ||
| 1714 | { | ||
| 1715 | 16818 | if (!scope) | |
| 1716 | 16242 | return; | |
| 1717 | 7137 | WORD original_buttons = 0; | |
| 1718 | 7640 | const bool sampled = result == ERROR_SUCCESS && state != nullptr && scope->admitted() && | |
| 1719 | 495 | scope->original_buttons(state->Gamepad.wButtons, original_buttons); | |
| 1720 | 7145 | if (!sampled) | |
| 1721 | { | ||
| 1722 | 6651 | (void)scope->finish(); | |
| 1723 | 6666 | return; | |
| 1724 | } | ||
| 1725 | 494 | const WORD returned_buttons = suppressed_xinput_buttons(state, user_index, original_buttons); | |
| 1726 | 494 | if (scope->finish(returned_buttons) && xinput_consume_context_healthy()) | |
| 1727 | 494 | state->Gamepad.wButtons = returned_buttons; | |
| 1728 | } | ||
| 1729 | |||
| 1730 | 13419 | DWORD WINAPI xinput_get_state_detour(DWORD user_index, XINPUT_STATE *state) noexcept | |
| 1731 | { | ||
| 1732 | 13419 | const InflightGuard inflight; | |
| 1733 | 13452 | std::optional<XInputConsumeScope> consume_scope; | |
| 1734 | 13452 | if (state != nullptr && !is_xinput_raw_call()) | |
| 1735 | 6898 | consume_scope.emplace(user_index, state); | |
| 1736 | 39980 | xinput_route_probe_record({ | |
| 1737 | 13329 | .owner = s_intercept_owner.load(std::memory_order_acquire), | |
| 1738 | 13319 | .hook_epoch = s_xinput_hook_epoch.load(std::memory_order_acquire), | |
| 1739 | .member = XInputRouteMember::Primary, | ||
| 1740 | .user_index = user_index, | ||
| 1741 | .state_address = reinterpret_cast<std::uintptr_t>(state), | ||
| 1742 | }); | ||
| 1743 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 1744 | 13332 | if (auto *seam = s_xinput_detour_body_seam.load(std::memory_order_acquire)) | |
| 1745 | { | ||
| 1746 | 28 | seam(); | |
| 1747 | } | ||
| 1748 | #endif | ||
| 1749 | // This seq_cst load forms the detour side of the Dekker drain pair. See InflightGuard. | ||
| 1750 | 13349 | const XInputGetStateFn original = s_xinput_original.load(std::memory_order_seq_cst); | |
| 1751 | 13298 | const DWORD result = (original != nullptr) ? original(user_index, state) : ERROR_DEVICE_NOT_CONNECTED; | |
| 1752 | 13383 | finish_xinput_consume_scope(consume_scope, state, user_index, result); | |
| 1753 | 26772 | return result; | |
| 1754 | 13317 | } | |
| 1755 | |||
| 1756 | 3486 | DWORD WINAPI xinput_get_state_ex_detour(DWORD user_index, XINPUT_STATE *state) noexcept | |
| 1757 | { | ||
| 1758 | 3486 | const InflightGuard inflight; | |
| 1759 | 3509 | std::optional<XInputConsumeScope> consume_scope; | |
| 1760 | 3509 | if (state != nullptr && !is_xinput_raw_call()) | |
| 1761 | 247 | consume_scope.emplace(user_index, state); | |
| 1762 | 10406 | xinput_route_probe_record({ | |
| 1763 | 3467 | .owner = s_intercept_owner.load(std::memory_order_acquire), | |
| 1764 | 3472 | .hook_epoch = s_xinput_hook_epoch.load(std::memory_order_acquire), | |
| 1765 | .member = XInputRouteMember::Ex, | ||
| 1766 | .user_index = user_index, | ||
| 1767 | .state_address = reinterpret_cast<std::uintptr_t>(state), | ||
| 1768 | }); | ||
| 1769 | // This seq_cst load serves the same Dekker-pair reason as xinput_get_state_detour above. | ||
| 1770 | 3467 | const XInputGetStateFn original = s_xinput_ex_original.load(std::memory_order_seq_cst); | |
| 1771 | 3471 | const DWORD result = (original != nullptr) ? original(user_index, state) : ERROR_DEVICE_NOT_CONNECTED; | |
| 1772 | 3466 | finish_xinput_consume_scope(consume_scope, state, user_index, result); | |
| 1773 | 6867 | return result; | |
| 1774 | 3406 | } | |
| 1775 | |||
| 1776 | // The local wheel source is a thread-scoped WH_GETMESSAGE hook on the selected game UI thread. It folds and | ||
| 1777 | // counts on PM_REMOVE only, at most once per retrieval, and a PM_NOREMOVE peek passes through uncounted. Count | ||
| 1778 | // admission runs before CallNextHookEx with no message mutation, so older hooks see the original record. | ||
| 1779 | // CallNextHookEx runs exactly once, and consume finalization writes WM_NULL after it returns, only while every | ||
| 1780 | // admission condition still holds. A newer hook can rewrite the message after this hook returns. The consume | ||
| 1781 | // stays best effort. | ||
| 1782 | 1192 | LRESULT CALLBACK message_hook_proc(int code, WPARAM wparam, LPARAM lparam) noexcept | |
| 1783 | { | ||
| 1784 | 1192 | if (code != HC_ACTION) | |
| 1785 | { | ||
| 1786 | ✗ | return CallNextHookEx(nullptr, code, wparam, lparam); | |
| 1787 | } | ||
| 1788 | 1192 | MSG *message = reinterpret_cast<MSG *>(lparam); | |
| 1789 | 1192 | if (message == nullptr) | |
| 1790 | { | ||
| 1791 | ✗ | return CallNextHookEx(nullptr, code, wparam, lparam); | |
| 1792 | } | ||
| 1793 | 1192 | const bool is_wheel = message->message == WM_MOUSEWHEEL || message->message == WM_MOUSEHWHEEL; | |
| 1794 | 1192 | if (!is_wheel || wparam != PM_REMOVE) | |
| 1795 | { | ||
| 1796 | 5 | return CallNextHookEx(nullptr, code, wparam, lparam); | |
| 1797 | } | ||
| 1798 | // Reject a callback whose current thread does not match the published route. A reused numeric id cannot | ||
| 1799 | // revive retired state: the route id is republished only by a mount transaction. | ||
| 1800 | 2374 | if (GetCurrentThreadId() != s_msg_hook_thread_id.load(std::memory_order_acquire)) | |
| 1801 | { | ||
| 1802 | ✗ | return CallNextHookEx(nullptr, code, wparam, lparam); | |
| 1803 | } | ||
| 1804 | 1187 | const std::uint64_t capture_state = s_wheel_capture_state.load(std::memory_order_seq_cst); | |
| 1805 | 1187 | const bool horizontal = message->message == WM_MOUSEHWHEEL; | |
| 1806 | 1187 | const int delta = GET_WHEEL_DELTA_WPARAM(message->wParam); | |
| 1807 | 1187 | const bool consume_intent = wheel_count_admission(horizontal, delta, capture_state); | |
| 1808 | 1187 | const LRESULT result = CallNextHookEx(nullptr, code, wparam, lparam); | |
| 1809 | 1187 | if (consume_intent && wheel_consume_finalization(horizontal, delta, capture_state)) | |
| 1810 | { | ||
| 1811 | 11 | message->message = WM_NULL; | |
| 1812 | 11 | message->wParam = 0; | |
| 1813 | 11 | message->lParam = 0; | |
| 1814 | } | ||
| 1815 | 1187 | return result; | |
| 1816 | } | ||
| 1817 | |||
| 1818 | /// Requires s_intercept_mutex. Reports whether the mounted route's target thread exited. | ||
| 1819 | 91 | [[nodiscard]] bool msg_hook_target_thread_exited_locked() noexcept | |
| 1820 | { | ||
| 1821 | 91 | return s_msg_hook_thread != nullptr && WaitForSingleObject(s_msg_hook_thread, 0) == WAIT_OBJECT_0; | |
| 1822 | } | ||
| 1823 | |||
| 1824 | /** | ||
| 1825 | * @brief Removes an OS hook, tolerating a handle the OS already reclaimed. | ||
| 1826 | * @details The poll thread installs the wheel hook, so the OS retires the thread-owned hook when that thread | ||
| 1827 | * exits. A later removal on the control thread then fails with an invalid-handle error, which is a | ||
| 1828 | * successful removal, not a live-thread cleanup failure. A genuine failure keeps the caller blocked. | ||
| 1829 | * @return true when the hook is gone (removed now, or already reclaimed). | ||
| 1830 | */ | ||
| 1831 | 31 | [[nodiscard]] bool unhook_or_already_gone(HHOOK hook) noexcept | |
| 1832 | { | ||
| 1833 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 1834 | 31 | if (s_force_message_unhook_failure.load(std::memory_order_acquire)) | |
| 1835 | { | ||
| 1836 | 2 | SetLastError(0); | |
| 1837 | } | ||
| 1838 | else | ||
| 1839 | #endif | ||
| 1840 | { | ||
| 1841 | 29 | SetLastError(0); | |
| 1842 | 29 | if (UnhookWindowsHookEx(hook) != 0) | |
| 1843 | { | ||
| 1844 | 21 | return true; | |
| 1845 | } | ||
| 1846 | } | ||
| 1847 | 10 | const DWORD error = GetLastError(); | |
| 1848 | 10 | return error == ERROR_INVALID_HOOK_HANDLE; | |
| 1849 | } | ||
| 1850 | |||
| 1851 | /** | ||
| 1852 | * @brief Requires s_intercept_mutex. Drops the mounted route after its target thread exited. | ||
| 1853 | * @details Thread exit is authoritative hook retirement: the OS hook died with its thread, so the unhook | ||
| 1854 | * call only releases the handle and its result carries no authority. | ||
| 1855 | */ | ||
| 1856 | 5 | void retire_message_hook_route_locked() noexcept | |
| 1857 | { | ||
| 1858 | 5 | if (const HHOOK hook = s_msg_hook.load(std::memory_order_relaxed); hook != nullptr) | |
| 1859 | { | ||
| 1860 | 5 | (void)UnhookWindowsHookEx(hook); | |
| 1861 | 5 | s_msg_hook.store(nullptr, std::memory_order_release); | |
| 1862 | } | ||
| 1863 | s_msg_hook_thread_id.store(0, std::memory_order_release); | ||
| 1864 | 5 | if (s_msg_hook_thread != nullptr) | |
| 1865 | { | ||
| 1866 | 5 | CloseHandle(s_msg_hook_thread); | |
| 1867 | 5 | s_msg_hook_thread = nullptr; | |
| 1868 | } | ||
| 1869 | 5 | const std::uint64_t wheel_epoch = close_wheel_capture_and_advance_epoch(); | |
| 1870 | { | ||
| 1871 | 5 | const DataPlaneLockGuard data_lock; | |
| 1872 | 5 | reset_wheel_data_plane(wheel_epoch); | |
| 1873 | 5 | } | |
| 1874 | s_msg_hook_route_state.store( | ||
| 1875 | static_cast<std::uint8_t>(WheelRouteState::Retryable), | ||
| 1876 | std::memory_order_release | ||
| 1877 | ); | ||
| 1878 | 5 | } | |
| 1879 | |||
| 1880 | /** | ||
| 1881 | * @brief Requires s_intercept_mutex. Rechecks target liveness and settles the route state. | ||
| 1882 | * @details A dead target cannot remain ready, and a cleanup-blocked route whose old thread exited becomes | ||
| 1883 | * retryable. | ||
| 1884 | */ | ||
| 1885 | 9867 | void settle_message_hook_route_locked() noexcept | |
| 1886 | { | ||
| 1887 | 9867 | if (s_msg_hook.load(std::memory_order_relaxed) != nullptr && msg_hook_target_thread_exited_locked()) | |
| 1888 | { | ||
| 1889 | 5 | retire_message_hook_route_locked(); | |
| 1890 | } | ||
| 1891 | 9867 | } | |
| 1892 | |||
| 1893 | 290 | void uninstall_message_hook() noexcept | |
| 1894 | { | ||
| 1895 | 290 | const HHOOK hook = s_msg_hook.load(std::memory_order_acquire); | |
| 1896 | 290 | if (hook == nullptr) | |
| 1897 | { | ||
| 1898 | 263 | return; | |
| 1899 | } | ||
| 1900 | // The caller's revocation already advanced the epoch, so admitted decisions are stale; the bounded | ||
| 1901 | // drain only waits out the short admission windows. The phase atomics are process-lifetime statics, so | ||
| 1902 | // a drain timeout is a correctness residual, not a memory hazard, and teardown still proceeds. | ||
| 1903 | 27 | (void)drain_wheel_admitted_phases(); | |
| 1904 | 27 | if (msg_hook_target_thread_exited_locked()) | |
| 1905 | { | ||
| 1906 | ✗ | retire_message_hook_route_locked(); | |
| 1907 | } | ||
| 1908 | 27 | else if (!unhook_or_already_gone(hook)) | |
| 1909 | { | ||
| 1910 | // Cleanup only. The permanent module reference is retained either way, because Microsoft permits a | ||
| 1911 | // selected callback to run after UnhookWindowsHookEx returns. | ||
| 1912 | s_msg_hook_route_state.store( | ||
| 1913 | static_cast<std::uint8_t>(WheelRouteState::CleanupBlocked), | ||
| 1914 | std::memory_order_release | ||
| 1915 | ); | ||
| 1916 | 1 | return; | |
| 1917 | } | ||
| 1918 | else | ||
| 1919 | { | ||
| 1920 | 26 | s_msg_hook.store(nullptr, std::memory_order_release); | |
| 1921 | s_msg_hook_thread_id.store(0, std::memory_order_release); | ||
| 1922 | 26 | if (s_msg_hook_thread != nullptr) | |
| 1923 | { | ||
| 1924 | 26 | CloseHandle(s_msg_hook_thread); | |
| 1925 | 26 | s_msg_hook_thread = nullptr; | |
| 1926 | } | ||
| 1927 | s_msg_hook_route_state.store( | ||
| 1928 | static_cast<std::uint8_t>(WheelRouteState::TargetWait), | ||
| 1929 | std::memory_order_release | ||
| 1930 | ); | ||
| 1931 | } | ||
| 1932 | // The caller's revocation already advanced the capture epoch and reset the data plane at that epoch. A | ||
| 1933 | // second advance here would leave the count and remainder slots tagged with a stale epoch, so the next | ||
| 1934 | // owner's mount would fold nothing. Do not advance again. | ||
| 1935 | } | ||
| 1936 | } // anonymous namespace | ||
| 1937 | |||
| 1938 | 1370 | uint8_t step_wheel_pulse(WheelPulseState &state) noexcept | |
| 1939 | { | ||
| 1940 | 1370 | uint8_t mask = 0; | |
| 1941 | 6850 | for (int dir = 0; dir < 4; ++dir) | |
| 1942 | { | ||
| 1943 | 5480 | if (state.pulsing[dir]) | |
| 1944 | { | ||
| 1945 | // Force one low cycle after a pulse so the edge detector re-arms. | ||
| 1946 | 26 | state.pulsing[dir] = false; | |
| 1947 | } | ||
| 1948 | 5454 | else if (state.pending[dir] > 0) | |
| 1949 | { | ||
| 1950 | 33 | --state.pending[dir]; | |
| 1951 | 33 | mask = static_cast<uint8_t>(mask | (1u << dir)); | |
| 1952 | 33 | state.pulsing[dir] = true; | |
| 1953 | } | ||
| 1954 | } | ||
| 1955 | 1370 | return mask; | |
| 1956 | } | ||
| 1957 | |||
| 1958 | 1431 | void add_wheel_notches(WheelPulseState &state, const std::array<int, 4> &taken) noexcept | |
| 1959 | { | ||
| 1960 | 7155 | for (size_t dir = 0; dir < 4; ++dir) | |
| 1961 | { | ||
| 1962 | 5724 | const int add = taken[dir] > 0 ? taken[dir] : 0; | |
| 1963 | // pending is in [0, MAX_WHEEL_PENDING] by induction, so room is nonnegative. Compare against room before | ||
| 1964 | // the addition so a large burst saturates instead of an int overflow. | ||
| 1965 | 5724 | const int room = MAX_WHEEL_PENDING - state.pending[dir]; | |
| 1966 | 5724 | state.pending[dir] = (add >= room) ? MAX_WHEEL_PENDING : state.pending[dir] + add; | |
| 1967 | } | ||
| 1968 | 1431 | } | |
| 1969 | |||
| 1970 | 1051 | uint16_t step_gamepad_suppress( | |
| 1971 | GamepadSuppressState &state, | ||
| 1972 | uint16_t owned_now, | ||
| 1973 | uint16_t true_buttons, | ||
| 1974 | uint64_t now_ms, | ||
| 1975 | uint64_t grace_ms | ||
| 1976 | ) noexcept | ||
| 1977 | { | ||
| 1978 | // Sentinel deadline denotes "actively held, with no release underway." | ||
| 1979 | 1051 | constexpr uint64_t held_sentinel = UINT64_MAX; | |
| 1980 | |||
| 1981 | 1051 | uint16_t mask = 0; | |
| 1982 | 1051 | const uint16_t relevant = static_cast<uint16_t>(state.armed | owned_now); | |
| 1983 | 17867 | for (int bit = 0; bit < 16; ++bit) | |
| 1984 | { | ||
| 1985 | 16816 | const uint16_t bit_mask = static_cast<uint16_t>(1u << bit); | |
| 1986 | 16816 | if ((relevant & bit_mask) == 0) | |
| 1987 | { | ||
| 1988 | 15767 | continue; | |
| 1989 | } | ||
| 1990 | 1049 | const bool phys_down = (true_buttons & bit_mask) != 0; | |
| 1991 | 1049 | const bool owned = (owned_now & bit_mask) != 0; | |
| 1992 | |||
| 1993 | 1049 | if (owned || ((state.armed & bit_mask) != 0 && phys_down)) | |
| 1994 | { | ||
| 1995 | // A current chord or a still-held trigger keeps suppression active after modifier release. Cancel any | ||
| 1996 | // active release grace. | ||
| 1997 | 1013 | state.armed = static_cast<uint16_t>(state.armed | bit_mask); | |
| 1998 | 1013 | state.deadline_ms[static_cast<size_t>(bit)] = held_sentinel; | |
| 1999 | 1013 | mask = static_cast<uint16_t>(mask | bit_mask); | |
| 2000 | } | ||
| 2001 | 36 | else if ((state.armed & bit_mask) != 0) | |
| 2002 | { | ||
| 2003 | // If the armed button is physically up, run the release grace so a final bare-trigger frame cannot | ||
| 2004 | // reach the game. | ||
| 2005 | 36 | if (state.deadline_ms[static_cast<size_t>(bit)] == held_sentinel) | |
| 2006 | { | ||
| 2007 | 22 | state.deadline_ms[static_cast<size_t>(bit)] = now_ms + grace_ms; | |
| 2008 | } | ||
| 2009 | 36 | if (now_ms < state.deadline_ms[static_cast<size_t>(bit)]) | |
| 2010 | { | ||
| 2011 | 33 | mask = static_cast<uint16_t>(mask | bit_mask); | |
| 2012 | } | ||
| 2013 | else | ||
| 2014 | { | ||
| 2015 | 3 | state.armed = static_cast<uint16_t>(state.armed & static_cast<uint16_t>(~bit_mask)); | |
| 2016 | 3 | state.deadline_ms[static_cast<size_t>(bit)] = 0; | |
| 2017 | } | ||
| 2018 | } | ||
| 2019 | } | ||
| 2020 | 1051 | return mask; | |
| 2021 | } | ||
| 2022 | |||
| 2023 | 296 | uint16_t evaluate_consume_rules(uint16_t true_buttons, const GamepadConsumeRule *rules, std::size_t count) noexcept | |
| 2024 | { | ||
| 2025 | 296 | uint16_t mask = 0; | |
| 2026 | 782 | for (std::size_t i = 0; i < count; ++i) | |
| 2027 | { | ||
| 2028 | 486 | const GamepadConsumeRule &rule = rules[i]; | |
| 2029 | // The rule requires every modifier bit and rejects every forbidden bit. This matches the poll loop's exact | ||
| 2030 | // decision against the snapshot that the game reads. A forbidden bit belongs to a different chord. | ||
| 2031 | 486 | if ((true_buttons & rule.modifier_mask) == rule.modifier_mask && (true_buttons & rule.forbidden_mask) == 0) | |
| 2032 | { | ||
| 2033 | 332 | mask = static_cast<uint16_t>(mask | rule.trigger_mask); | |
| 2034 | } | ||
| 2035 | } | ||
| 2036 | 296 | return mask; | |
| 2037 | } | ||
| 2038 | |||
| 2039 | ConsumePublish | ||
| 2040 | 4179 | publish_gamepad_consume_rules(const GamepadConsumeRule *rules, std::size_t count, std::uint64_t owner) noexcept | |
| 2041 | { | ||
| 2042 | 4179 | run_data_plane_entry_seam(); | |
| 2043 | 4179 | const DataPlaneLockGuard data_lock; | |
| 2044 | 4179 | if (!data_plane_authorized(owner)) | |
| 2045 | { | ||
| 2046 | // Refuse before the seqlock bracket opens, so an unauthorized caller leaves the sequence untouched and | ||
| 2047 | // even. | ||
| 2048 | 3985 | return {}; | |
| 2049 | } | ||
| 2050 | |||
| 2051 | // Keep the rules that fit and drop the rest. Each retained rule protects its chord. An empty list revokes all | ||
| 2052 | // initial-edge protection because one rule did not fit. | ||
| 2053 | 194 | const std::size_t published = count < MAX_GAMEPAD_CONSUME_RULES ? count : MAX_GAMEPAD_CONSUME_RULES; | |
| 2054 | // This writer brackets the update with an odd sequence. The release fence keeps rule stores inside that | ||
| 2055 | // bracket. The even release store publishes the final list. | ||
| 2056 | 194 | const uint32_t seq = s_consume_rules_seq.load(std::memory_order_relaxed); | |
| 2057 | 194 | s_consume_rules_seq.store(seq + 1, std::memory_order_relaxed); | |
| 2058 | std::atomic_thread_fence(std::memory_order_release); | ||
| 2059 | 465 | for (std::size_t i = 0; i < published; ++i) | |
| 2060 | { | ||
| 2061 | 271 | s_consume_rules[i].store(pack_consume_rule(rules[i]), std::memory_order_relaxed); | |
| 2062 | } | ||
| 2063 | 194 | s_consume_rule_count.store(static_cast<uint32_t>(published), std::memory_order_relaxed); | |
| 2064 | 194 | s_consume_rules_seq.store(seq + 2, std::memory_order_release); | |
| 2065 | 194 | return {true, published}; | |
| 2066 | 4179 | } | |
| 2067 | |||
| 2068 | 280 | uint16_t evaluate_published_consume_rules(uint16_t true_buttons) noexcept | |
| 2069 | { | ||
| 2070 | // Seqlock read with one attempt and no spin. On an odd sequence or torn snapshot, skip rule suppression for | ||
| 2071 | // this frame. The reactive mask still applies. The next game poll gets the settled list. | ||
| 2072 | 280 | const uint32_t seq_before = s_consume_rules_seq.load(std::memory_order_acquire); | |
| 2073 | 280 | if ((seq_before & 1u) != 0) | |
| 2074 | { | ||
| 2075 | ✗ | return 0; | |
| 2076 | } | ||
| 2077 | 280 | uint32_t count = s_consume_rule_count.load(std::memory_order_relaxed); | |
| 2078 | 280 | if (count > MAX_GAMEPAD_CONSUME_RULES) | |
| 2079 | { | ||
| 2080 | ✗ | count = MAX_GAMEPAD_CONSUME_RULES; | |
| 2081 | } | ||
| 2082 | 280 | std::array<GamepadConsumeRule, MAX_GAMEPAD_CONSUME_RULES> snapshot{}; | |
| 2083 | 743 | for (uint32_t i = 0; i < count; ++i) | |
| 2084 | { | ||
| 2085 | 926 | snapshot[i] = unpack_consume_rule(s_consume_rules[i].load(std::memory_order_relaxed)); | |
| 2086 | } | ||
| 2087 | // Order the rule loads before the sequence re-read, so a mid-copy writer is always detected. | ||
| 2088 | std::atomic_thread_fence(std::memory_order_acquire); | ||
| 2089 | 280 | if (s_consume_rules_seq.load(std::memory_order_relaxed) != seq_before) | |
| 2090 | { | ||
| 2091 | ✗ | return 0; | |
| 2092 | } | ||
| 2093 | 560 | return evaluate_consume_rules(true_buttons, snapshot.data(), count); | |
| 2094 | } | ||
| 2095 | |||
| 2096 | 1050 | bool set_gamepad_rule_suppress_enabled(bool enabled, std::uint64_t owner) noexcept | |
| 2097 | { | ||
| 2098 | 1050 | run_data_plane_entry_seam(); | |
| 2099 | 1050 | const DataPlaneLockGuard data_lock; | |
| 2100 | 1050 | if (!data_plane_authorized(owner)) | |
| 2101 | { | ||
| 2102 | 1 | return false; | |
| 2103 | } | ||
| 2104 | 1049 | s_rule_suppress_enabled.store(enabled, std::memory_order_relaxed); | |
| 2105 | 1049 | return true; | |
| 2106 | 1050 | } | |
| 2107 | |||
| 2108 | 615 | std::uint64_t next_intercept_owner() noexcept | |
| 2109 | { | ||
| 2110 | for (;;) | ||
| 2111 | { | ||
| 2112 | 615 | const std::uint64_t owner = s_next_intercept_owner.fetch_add(1, std::memory_order_relaxed); | |
| 2113 | 615 | if (owner != 0 && owner != STANDALONE_INTERCEPT_OWNER) | |
| 2114 | { | ||
| 2115 | 615 | return owner; | |
| 2116 | } | ||
| 2117 | ✗ | } | |
| 2118 | } | ||
| 2119 | |||
| 2120 | 1733 | bool intercept_owned_by(std::uint64_t owner) noexcept | |
| 2121 | { | ||
| 2122 | 3465 | return owner != 0 && s_intercept_owner.load(std::memory_order_acquire) == owner; | |
| 2123 | } | ||
| 2124 | |||
| 2125 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 2126 | 51 | bool acquire_standalone_lease_for_test() noexcept | |
| 2127 | { | ||
| 2128 | 51 | const InterceptLockGuard lock{s_intercept_mutex}; | |
| 2129 | 51 | if (!owner_available(STANDALONE_INTERCEPT_OWNER)) | |
| 2130 | { | ||
| 2131 | ✗ | return false; | |
| 2132 | } | ||
| 2133 | 51 | publish_owner(STANDALONE_INTERCEPT_OWNER); | |
| 2134 | 51 | return true; | |
| 2135 | 51 | } | |
| 2136 | |||
| 2137 | 38 | bool xinput_pair_degraded_for_test() noexcept | |
| 2138 | { | ||
| 2139 | 38 | return s_xinput_pair_degraded.load(std::memory_order_acquire); | |
| 2140 | } | ||
| 2141 | |||
| 2142 | 114 | XInputPairCoverage xinput_pair_coverage_for_test() noexcept | |
| 2143 | { | ||
| 2144 | 114 | const InterceptLockGuard lock{s_intercept_mutex}; | |
| 2145 | 114 | if (s_xinput_permanent_hooks == nullptr) | |
| 2146 | { | ||
| 2147 | ✗ | return XInputPairCoverage{false, true}; | |
| 2148 | } | ||
| 2149 | return XInputPairCoverage{ | ||
| 2150 | 114 | xinput_member_entry_witnessed(s_xinput_permanent_hooks->primary, true), | |
| 2151 | 114 | xinput_member_entry_witnessed(s_xinput_permanent_hooks->ex, static_cast<bool>(s_xinput_permanent_hooks->ex)) | |
| 2152 | 228 | }; | |
| 2153 | 114 | } | |
| 2154 | |||
| 2155 | 95 | XInputPairCoverage xinput_pair_observation_for_test() noexcept | |
| 2156 | { | ||
| 2157 | 95 | const InterceptLockGuard lock{s_intercept_mutex}; | |
| 2158 | 190 | return XInputPairCoverage{s_xinput_primary_observed, s_xinput_ex_observed}; | |
| 2159 | 95 | } | |
| 2160 | |||
| 2161 | 9 | std::size_t xinput_recovery_attempts_for_test() noexcept | |
| 2162 | { | ||
| 2163 | 9 | return s_xinput_recovery_attempts.load(std::memory_order_relaxed); | |
| 2164 | } | ||
| 2165 | |||
| 2166 | 11 | std::uint64_t expire_xinput_recovery_delay_for_test() noexcept | |
| 2167 | { | ||
| 2168 | 11 | const InterceptLockGuard lock{s_intercept_mutex}; | |
| 2169 | 11 | const std::uint64_t delay_ms = s_xinput_recovery_delay_ms; | |
| 2170 | 11 | s_xinput_recovery_not_before_ms = 0; | |
| 2171 | 22 | return delay_ms; | |
| 2172 | 11 | } | |
| 2173 | |||
| 2174 | 53 | bool adopt_owner_for_test(std::uint64_t owner) noexcept | |
| 2175 | { | ||
| 2176 | 53 | const InterceptLockGuard lock{s_intercept_mutex}; | |
| 2177 | 53 | if (!owner_available(owner)) | |
| 2178 | { | ||
| 2179 | ✗ | return false; | |
| 2180 | } | ||
| 2181 | 53 | publish_owner(owner); | |
| 2182 | 53 | return true; | |
| 2183 | 53 | } | |
| 2184 | 51 | void release_standalone_lease_for_test() noexcept | |
| 2185 | { | ||
| 2186 | 51 | const InterceptLockGuard lock{s_intercept_mutex}; | |
| 2187 | 51 | if (s_intercept_owner.load(std::memory_order_relaxed) != STANDALONE_INTERCEPT_OWNER) | |
| 2188 | { | ||
| 2189 | 18 | return; | |
| 2190 | } | ||
| 2191 | 33 | revoke_owner_and_clear_data(); | |
| 2192 | 51 | } | |
| 2193 | #endif | ||
| 2194 | |||
| 2195 | namespace | ||
| 2196 | { | ||
| 2197 | /** | ||
| 2198 | * @brief Runs the maintenance transaction over the permanently retained hook pair. | ||
| 2199 | * @note Requires s_intercept_mutex and a constructed process-lifetime cell. | ||
| 2200 | */ | ||
| 2201 | 74 | [[nodiscard]] bool maintain_retained_xinput_pair(int user_index, std::uint64_t owner) noexcept | |
| 2202 | { | ||
| 2203 | 74 | PermanentXInputHooks *const permanent = permanent_cell(); | |
| 2204 | 74 | if (!permanent->primary) | |
| 2205 | { | ||
| 2206 | // No primary storage remains. A fresh hook over the current export creates the uncertain-storage case | ||
| 2207 | // that retention avoids. | ||
| 2208 | ✗ | return false; | |
| 2209 | } | ||
| 2210 | // Recovery re-arms each absent member through its retained hook. It never creates a second hook over the | ||
| 2211 | // current prologue. | ||
| 2212 | 74 | const bool whole = maintain_xinput_pair( | |
| 2213 | 74 | permanent->primary, | |
| 2214 | 74 | permanent->ex, | |
| 2215 | 74 | permanent->ex_target_ref != nullptr ? permanent->ex_target_ref : permanent->target_ref, | |
| 2216 | user_index, | ||
| 2217 | owner | ||
| 2218 | ); | ||
| 2219 | 74 | return whole; | |
| 2220 | } | ||
| 2221 | |||
| 2222 | /** | ||
| 2223 | * @brief Runs the maintenance transaction over the live pair. | ||
| 2224 | * @note Requires s_intercept_mutex and a live primary member. | ||
| 2225 | */ | ||
| 2226 | 1586 | [[nodiscard]] bool maintain_live_xinput_pair(int user_index, std::uint64_t owner) noexcept | |
| 2227 | { | ||
| 2228 | 4758 | return maintain_xinput_pair( | |
| 2229 | 1586 | s_xinput_permanent_hooks->primary, | |
| 2230 | 1586 | s_xinput_permanent_hooks->ex, | |
| 2231 | 1586 | s_xinput_permanent_hooks->ex_target_ref != nullptr ? s_xinput_permanent_hooks->ex_target_ref | |
| 2232 | 1586 | : s_xinput_permanent_hooks->target_ref, | |
| 2233 | user_index, | ||
| 2234 | owner | ||
| 2235 | 1586 | ); | |
| 2236 | } | ||
| 2237 | |||
| 2238 | /** | ||
| 2239 | * @brief Returns the loaded XInput module, or nullptr if no XInput DLL is loaded. | ||
| 2240 | * @note Requires s_intercept_mutex. | ||
| 2241 | */ | ||
| 2242 | 826 | [[nodiscard]] HMODULE find_loaded_xinput_module() noexcept | |
| 2243 | { | ||
| 2244 | 826 | HMODULE module = nullptr; | |
| 2245 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 2246 | 826 | if (s_xinput_module_override != nullptr) | |
| 2247 | { | ||
| 2248 | 770 | module = s_xinput_module_override; | |
| 2249 | } | ||
| 2250 | else | ||
| 2251 | #endif | ||
| 2252 | { | ||
| 2253 | 56 | for (const wchar_t *name : XINPUT_DLL_NAMES) | |
| 2254 | { | ||
| 2255 | 56 | module = GetModuleHandleW(name); | |
| 2256 | 56 | if (module != nullptr) | |
| 2257 | { | ||
| 2258 | 56 | break; | |
| 2259 | } | ||
| 2260 | } | ||
| 2261 | } | ||
| 2262 | 826 | return module; | |
| 2263 | } | ||
| 2264 | |||
| 2265 | /** | ||
| 2266 | * @brief Takes every keepalive that the hook pair needs. | ||
| 2267 | * @details The caller runs this before any prologue patch because the retention teardown has no allocator or | ||
| 2268 | * loader call. A refusal fails closed, and the poll loop retries. | ||
| 2269 | * @return False when a keepalive acquisition fails. A false return leaves no keepalive held. | ||
| 2270 | * @note Requires s_intercept_mutex and a constructed process-lifetime cell. | ||
| 2271 | */ | ||
| 2272 | [[nodiscard]] bool | ||
| 2273 | 212 | acquire_xinput_keepalives(const void *get_state, const void *get_state_ex, bool ex_is_distinct_member) noexcept | |
| 2274 | { | ||
| 2275 | 424 | s_xinput_permanent_hooks->self_ref = | |
| 2276 | 212 | DetourModKit::detail::acquire_module_ref(diagnostics::ModulePinReason::XInputKeepalive); | |
| 2277 | 212 | if (s_xinput_permanent_hooks->self_ref == nullptr) | |
| 2278 | { | ||
| 2279 | ✗ | return false; | |
| 2280 | } | ||
| 2281 | 212 | s_xinput_permanent_hooks->target_ref = acquire_module_ref_containing_address(get_state); | |
| 2282 | 212 | if (s_xinput_permanent_hooks->target_ref == nullptr) | |
| 2283 | { | ||
| 2284 | ✗ | retire_xinput_module_refs(); | |
| 2285 | ✗ | return false; | |
| 2286 | } | ||
| 2287 | 212 | if (ex_is_distinct_member) | |
| 2288 | { | ||
| 2289 | 194 | const HMODULE ex_target_ref = acquire_module_ref_containing_address(get_state_ex); | |
| 2290 | 194 | if (ex_target_ref == nullptr) | |
| 2291 | { | ||
| 2292 | ✗ | retire_xinput_module_refs(); | |
| 2293 | ✗ | return false; | |
| 2294 | } | ||
| 2295 | 194 | if (ex_target_ref == s_xinput_permanent_hooks->target_ref) | |
| 2296 | { | ||
| 2297 | // The primary pin already covers this prologue. Balance the duplicate probe reference now. | ||
| 2298 | 193 | DetourModKit::detail::release_module_ref(ex_target_ref, diagnostics::ModulePinReason::XInputTarget); | |
| 2299 | } | ||
| 2300 | else | ||
| 2301 | { | ||
| 2302 | 1 | s_xinput_permanent_hooks->ex_target_ref = ex_target_ref; | |
| 2303 | } | ||
| 2304 | } | ||
| 2305 | 212 | return true; | |
| 2306 | } | ||
| 2307 | |||
| 2308 | /** | ||
| 2309 | * @brief Warns once about the refused pair credit and releases the install keepalives. | ||
| 2310 | * @note Requires s_intercept_mutex. | ||
| 2311 | */ | ||
| 2312 | 1 | void retire_xinput_install_after_credit_refusal() noexcept | |
| 2313 | { | ||
| 2314 | 1 | if (!s_xinput_capacity_warned.exchange(true, std::memory_order_relaxed)) | |
| 2315 | { | ||
| 2316 | 2 | (void)log().log_noexcept( | |
| 2317 | LogLevel::Warning, | ||
| 2318 | 1 | "InputIntercept: the routed retention ceiling refused the XInput hook pair, " | |
| 2319 | "so no XInput interception was installed and both entries remain open." | ||
| 2320 | ); | ||
| 2321 | } | ||
| 2322 | 1 | retire_xinput_module_refs(); | |
| 2323 | 1 | } | |
| 2324 | |||
| 2325 | /** | ||
| 2326 | * @brief Retains the created pair after the primary arm wrote its prologue and then lost it. | ||
| 2327 | * @note Requires s_intercept_mutex and a constructed process-lifetime cell. | ||
| 2328 | */ | ||
| 2329 | 1 | void retain_unproved_xinput_install(XInputRetentionLog &deferred_log) noexcept | |
| 2330 | { | ||
| 2331 | // A game thread can hold the primary trampoline, and nothing here can drain it. Retain the created, | ||
| 2332 | // disabled ordinal-100 member too. Recovery must arm that retained object rather than treat an empty slot | ||
| 2333 | // as the absent/alias exemption. | ||
| 2334 | const XInputPublishedChains published_chains{ | ||
| 2335 | 1 | s_xinput_original.load(std::memory_order_seq_cst) != nullptr, | |
| 2336 | 2 | s_xinput_ex_original.load(std::memory_order_seq_cst) != nullptr | |
| 2337 | 1 | }; | |
| 2338 | 1 | retain_xinput_hooks( | |
| 2339 | PatchWitness::Original, | ||
| 2340 | PatchWitness::Original, | ||
| 2341 | XInputRetentionReason::UnprovedInstall, | ||
| 2342 | deferred_log, | ||
| 2343 | published_chains | ||
| 2344 | ); | ||
| 2345 | 1 | } | |
| 2346 | |||
| 2347 | /** | ||
| 2348 | * @brief Arms the created Ex member, if any, and publishes the pair only if both members are whole. | ||
| 2349 | * @return True when this call published the pair. | ||
| 2350 | * @note Requires s_intercept_mutex and an armed primary member. | ||
| 2351 | */ | ||
| 2352 | [[nodiscard]] bool | ||
| 2353 | 206 | arm_ex_member_and_publish_xinput_pair(bool ex_is_distinct_member, int user_index, std::uint64_t owner) noexcept | |
| 2354 | { | ||
| 2355 | 206 | if (ex_is_distinct_member) | |
| 2356 | { | ||
| 2357 | 188 | (void)arm_created_xinput_hook( | |
| 2358 | 188 | s_xinput_permanent_hooks->ex, | |
| 2359 | s_xinput_ex_original, | ||
| 2360 | s_xinput_ex_enable_warned, | ||
| 2361 | 188 | "InputIntercept: the XInputGetStateEx hook transaction did not complete cleanly, " | |
| 2362 | "so XInput coverage stays degraded and both entries pass through." | ||
| 2363 | ); | ||
| 2364 | } | ||
| 2365 | |||
| 2366 | // The final pair witness reads both prologues before the store that enables suppression. A member | ||
| 2367 | // restored by a rival writer degrades the pair and prevents publication of incomplete coverage. | ||
| 2368 | 206 | if (publish_xinput_pair_if_whole( | |
| 2369 | 206 | s_xinput_permanent_hooks->primary, | |
| 2370 | 206 | s_xinput_permanent_hooks->ex, | |
| 2371 | user_index, | ||
| 2372 | owner | ||
| 2373 | )) | ||
| 2374 | { | ||
| 2375 | 157 | return true; | |
| 2376 | } | ||
| 2377 | // Clear the gate rather than defer: the next poll cycle attempts the first recovery immediately. | ||
| 2378 | 49 | xinput_recovery_reset(); | |
| 2379 | 49 | return false; | |
| 2380 | } | ||
| 2381 | } // anonymous namespace | ||
| 2382 | |||
| 2383 | namespace | ||
| 2384 | { | ||
| 2385 | [[nodiscard]] DWORD | ||
| 2386 | 1652 | invoke_xinput_export(XInputGetStateFn target, DWORD user_index, XINPUT_STATE &state) noexcept | |
| 2387 | { | ||
| 2388 | try | ||
| 2389 | { | ||
| 2390 | 1652 | return target(user_index, &state); | |
| 2391 | } | ||
| 2392 | ✗ | catch (...) | |
| 2393 | { | ||
| 2394 | ✗ | return ERROR_INVALID_FUNCTION; | |
| 2395 | ✗ | } | |
| 2396 | } | ||
| 2397 | } // anonymous namespace | ||
| 2398 | |||
| 2399 | namespace | ||
| 2400 | { | ||
| 2401 | struct XInputMemberSnapshot | ||
| 2402 | { | ||
| 2403 | 2542 | XInputGetStateFn target{nullptr}; | |
| 2404 | 2542 | XInputGetStateFn original{nullptr}; | |
| 2405 | 2542 | std::array<std::uint8_t, BACKEND_MAX_STEAL_WINDOW> bytes{}; | |
| 2406 | 2541 | std::size_t byte_count{0}; | |
| 2407 | 2541 | PatchWitness witness{PatchWitness::Original}; | |
| 2408 | 2541 | bool enabled{false}; | |
| 2409 | 2541 | bool readable{true}; | |
| 2410 | |||
| 2411 | 2542 | [[nodiscard]] bool operator==(const XInputMemberSnapshot &) const noexcept = default; | |
| 2412 | }; | ||
| 2413 | |||
| 2414 | 5374 | [[nodiscard]] XInputMemberSnapshot capture_xinput_member(const safetyhook::InlineHook &hook) noexcept | |
| 2415 | { | ||
| 2416 | 5374 | XInputMemberSnapshot snapshot; | |
| 2417 | 5374 | if (!hook) | |
| 2418 | 2 | return snapshot; | |
| 2419 | 5372 | snapshot.target = reinterpret_cast<XInputGetStateFn>(hook.target()); | |
| 2420 | 5372 | snapshot.original = hook.original<XInputGetStateFn>(); | |
| 2421 | 5372 | snapshot.byte_count = hook.original_bytes().size(); | |
| 2422 | 5372 | snapshot.enabled = hook.enabled(); | |
| 2423 | 5372 | snapshot.witness = xinput_patch_witness(hook); | |
| 2424 | 16116 | snapshot.readable = snapshot.byte_count != 0 && snapshot.byte_count <= snapshot.bytes.size() && | |
| 2425 | 10744 | guarded_read_bytes(hook.target_address(), snapshot.bytes.data(), snapshot.byte_count); | |
| 2426 | 5372 | return snapshot; | |
| 2427 | } | ||
| 2428 | |||
| 2429 | 2827 | [[nodiscard]] bool observed_route_candidate(const XInputMemberSnapshot &member, bool required) noexcept | |
| 2430 | { | ||
| 2431 | 2827 | if (member.target == nullptr) | |
| 2432 | 1 | return !required; | |
| 2433 | 5510 | return member.enabled && member.readable && | |
| 2434 | 5510 | (member.witness == PatchWitness::OwnedPatch || member.witness == PatchWitness::Foreign); | |
| 2435 | } | ||
| 2436 | |||
| 2437 | enum class XInputCallOutcome : std::uint8_t | ||
| 2438 | { | ||
| 2439 | Absent, | ||
| 2440 | Uninvoked, | ||
| 2441 | Returned, | ||
| 2442 | Exception, | ||
| 2443 | }; | ||
| 2444 | |||
| 2445 | struct XInputCallEvidence | ||
| 2446 | { | ||
| 2447 | XInputCallOutcome outcome{XInputCallOutcome::Uninvoked}; | ||
| 2448 | DWORD result{0}; | ||
| 2449 | bool authenticated{false}; | ||
| 2450 | std::optional<bool> admitted{}; | ||
| 2451 | }; | ||
| 2452 | |||
| 2453 | struct XInputPairEvidence | ||
| 2454 | { | ||
| 2455 | XInputCallEvidence primary{}; | ||
| 2456 | XInputCallEvidence ex{}; | ||
| 2457 | std::optional<bool> admitted{}; | ||
| 2458 | std::optional<bool> raw_admitted{}; | ||
| 2459 | std::optional<bool> raw_restored{}; | ||
| 2460 | std::optional<bool> unchanged{}; | ||
| 2461 | std::optional<bool> healthy{}; | ||
| 2462 | }; | ||
| 2463 | |||
| 2464 | [[nodiscard]] XInputCallEvidence | ||
| 2465 | 2541 | observe_xinput_export(XInputGetStateFn target, DWORD user_index, XINPUT_STATE &state) noexcept | |
| 2466 | { | ||
| 2467 | try | ||
| 2468 | { | ||
| 2469 | return { | ||
| 2470 | .outcome = XInputCallOutcome::Returned, | ||
| 2471 | 5057 | .result = target(user_index, &state), | |
| 2472 | 2541 | }; | |
| 2473 | } | ||
| 2474 | 24 | catch (...) | |
| 2475 | { | ||
| 2476 | return { | ||
| 2477 | .outcome = XInputCallOutcome::Exception, | ||
| 2478 | 24 | }; | |
| 2479 | 24 | } | |
| 2480 | } | ||
| 2481 | |||
| 2482 | 141 | void append_decimal(std::span<char> buffer, std::size_t &length, std::uint32_t value) noexcept | |
| 2483 | { | ||
| 2484 | 141 | std::array<char, 10> digits{}; | |
| 2485 | 423 | const auto converted = std::to_chars(digits.data(), digits.data() + digits.size(), value); | |
| 2486 | 282 | append_text(buffer, length, {digits.data(), static_cast<std::size_t>(converted.ptr - digits.data())}); | |
| 2487 | 141 | } | |
| 2488 | |||
| 2489 | 294 | [[nodiscard]] std::string_view check_description(std::optional<bool> result) noexcept | |
| 2490 | { | ||
| 2491 | 294 | return result ? (*result ? "passed" : "failed") : "unmeasured"; | |
| 2492 | } | ||
| 2493 | |||
| 2494 | 84 | void append_xinput_call( | |
| 2495 | std::span<char> buffer, | ||
| 2496 | std::size_t &length, | ||
| 2497 | std::string_view name, | ||
| 2498 | const XInputMemberSnapshot &member, | ||
| 2499 | const XInputCallEvidence &call | ||
| 2500 | ) noexcept | ||
| 2501 | { | ||
| 2502 | 84 | append_text(buffer, length, name); | |
| 2503 | 84 | append_text(buffer, length, ": witness="); | |
| 2504 | 84 | append_text(buffer, length, witness_description(member.witness)); | |
| 2505 | 84 | append_text(buffer, length, ", enabled="); | |
| 2506 | 84 | append_text(buffer, length, member.enabled ? "yes" : "no"); | |
| 2507 | 84 | append_text(buffer, length, ", readable="); | |
| 2508 | 84 | append_text(buffer, length, member.readable ? "yes" : "no"); | |
| 2509 | 84 | append_text(buffer, length, ", probe="); | |
| 2510 | 84 | append_text(buffer, length, check_description(call.admitted)); | |
| 2511 | 84 | append_text(buffer, length, ", call="); | |
| 2512 | 84 | switch (call.outcome) | |
| 2513 | { | ||
| 2514 | 1 | case XInputCallOutcome::Absent: | |
| 2515 | 1 | append_text(buffer, length, "absent"); | |
| 2516 | 1 | break; | |
| 2517 | 10 | case XInputCallOutcome::Uninvoked: | |
| 2518 | 10 | append_text(buffer, length, "uninvoked"); | |
| 2519 | 10 | break; | |
| 2520 | 3 | case XInputCallOutcome::Exception: | |
| 2521 | 3 | append_text(buffer, length, "exception"); | |
| 2522 | 3 | break; | |
| 2523 | 70 | case XInputCallOutcome::Returned: | |
| 2524 | 70 | append_text(buffer, length, "returned result="); | |
| 2525 | 70 | append_decimal(buffer, length, call.result); | |
| 2526 | 70 | break; | |
| 2527 | } | ||
| 2528 | 84 | append_text(buffer, length, ", authentication="); | |
| 2529 | 84 | append_text( | |
| 2530 | buffer, | ||
| 2531 | length, | ||
| 2532 | 83 | call.outcome == XInputCallOutcome::Absent || call.outcome == XInputCallOutcome::Uninvoked | |
| 2533 | 240 | ? "unmeasured" | |
| 2534 | 73 | : (call.authenticated ? "matched" : "failed") | |
| 2535 | ); | ||
| 2536 | 84 | } | |
| 2537 | |||
| 2538 | 421 | void degrade_observed_xinput_pair( | |
| 2539 | bool was_complete, | ||
| 2540 | int user_index, | ||
| 2541 | const XInputMemberSnapshot &primary, | ||
| 2542 | const XInputMemberSnapshot &ex, | ||
| 2543 | const XInputPairEvidence &evidence, | ||
| 2544 | InterceptLockGuard &lock | ||
| 2545 | ) noexcept | ||
| 2546 | { | ||
| 2547 | 421 | s_xinput_observed_pair = false; | |
| 2548 | 421 | s_xinput_primary_observed = false; | |
| 2549 | 421 | s_xinput_ex_observed = false; | |
| 2550 | 421 | s_xinput_installed.store(false, std::memory_order_release); | |
| 2551 | 421 | s_xinput_pair_degraded.store(true, std::memory_order_release); | |
| 2552 | 421 | const bool warn = was_complete && !s_xinput_route_warned; | |
| 2553 | 421 | std::array<char, 1024> message{}; | |
| 2554 | 421 | std::size_t length = 0; | |
| 2555 | 421 | if (warn) | |
| 2556 | { | ||
| 2557 | 42 | s_xinput_route_warned = true; | |
| 2558 | 42 | append_text(message, length, "InputIntercept: XInput paired suppression stopped: controller="); | |
| 2559 | 42 | append_decimal(message, length, static_cast<std::uint32_t>(user_index)); | |
| 2560 | 42 | append_text(message, length, ", pair: admission="); | |
| 2561 | 42 | append_text(message, length, check_description(evidence.admitted)); | |
| 2562 | 42 | append_text(message, length, ", raw_scope="); | |
| 2563 | 42 | append_text(message, length, check_description(evidence.raw_admitted)); | |
| 2564 | 42 | append_text(message, length, ", raw_restore="); | |
| 2565 | 42 | append_text(message, length, check_description(evidence.raw_restored)); | |
| 2566 | 42 | append_text(message, length, ", snapshot="); | |
| 2567 | 42 | append_text(message, length, check_description(evidence.unchanged)); | |
| 2568 | 42 | append_text(message, length, ", context="); | |
| 2569 | 42 | append_text(message, length, check_description(evidence.healthy)); | |
| 2570 | 42 | append_text(message, length, ". "); | |
| 2571 | 42 | append_xinput_call(message, length, "XInputGetState", primary, evidence.primary); | |
| 2572 | 42 | append_text(message, length, ". "); | |
| 2573 | 42 | append_xinput_call(message, length, "XInputGetStateEx", ex, evidence.ex); | |
| 2574 | 42 | append_text(message, length, "."); | |
| 2575 | } | ||
| 2576 | 421 | lock.unlock(); | |
| 2577 | 421 | if (warn) | |
| 2578 | { | ||
| 2579 | 84 | (void)log().log_noexcept(LogLevel::Warning, std::string_view{message.data(), length}); | |
| 2580 | } | ||
| 2581 | 421 | } | |
| 2582 | |||
| 2583 | 1416 | [[nodiscard]] bool observe_xinput_pair( | |
| 2584 | int user_index, | ||
| 2585 | std::uint64_t owner, | ||
| 2586 | bool was_complete, | ||
| 2587 | bool &recovered, | ||
| 2588 | InterceptLockGuard &lock | ||
| 2589 | ) noexcept | ||
| 2590 | { | ||
| 2591 | 1416 | const std::uint64_t epoch = s_xinput_hook_epoch.load(std::memory_order_seq_cst); | |
| 2592 | 1416 | if (s_intercept_owner.load(std::memory_order_acquire) != owner) | |
| 2593 | ✗ | return false; | |
| 2594 | 1416 | const XInputMemberSnapshot primary = capture_xinput_member(s_xinput_permanent_hooks->primary); | |
| 2595 | 1416 | const XInputMemberSnapshot ex = capture_xinput_member(s_xinput_permanent_hooks->ex); | |
| 2596 | 1416 | XInputPairEvidence evidence; | |
| 2597 | 1416 | if (ex.target == nullptr) | |
| 2598 | 1 | evidence.ex.outcome = XInputCallOutcome::Absent; | |
| 2599 | 1416 | if (user_index < 0 || user_index >= XUSER_MAX_COUNT || !reserve_xinput_route_probe()) | |
| 2600 | { | ||
| 2601 | 1 | evidence.admitted = false; | |
| 2602 | 1 | degrade_observed_xinput_pair(was_complete, user_index, primary, ex, evidence, lock); | |
| 2603 | 1 | return false; | |
| 2604 | } | ||
| 2605 | 1415 | const bool ex_required = ex.target != nullptr; | |
| 2606 | 1415 | if (!observed_route_candidate(primary, true) || !observed_route_candidate(ex, ex_required)) | |
| 2607 | { | ||
| 2608 | 142 | degrade_observed_xinput_pair(was_complete, user_index, primary, ex, evidence, lock); | |
| 2609 | 142 | return false; | |
| 2610 | } | ||
| 2611 | |||
| 2612 | 1273 | XINPUT_STATE primary_state{}; | |
| 2613 | 1273 | XINPUT_STATE ex_state{}; | |
| 2614 | 1273 | XInputRawScope raw_scope; | |
| 2615 | XInputRouteProbe primary_probe({ | ||
| 2616 | .owner = owner, | ||
| 2617 | .hook_epoch = epoch, | ||
| 2618 | .member = XInputRouteMember::Primary, | ||
| 2619 | 1273 | .user_index = static_cast<std::uint32_t>(user_index), | |
| 2620 | .state_address = reinterpret_cast<std::uintptr_t>(&primary_state), | ||
| 2621 | 1273 | }); | |
| 2622 | 1273 | std::optional<XInputRouteProbe> ex_probe; | |
| 2623 | 1273 | if (ex_required) | |
| 2624 | { | ||
| 2625 | 1272 | ex_probe.emplace( | |
| 2626 | 1272 | XInputRouteProbeIdentity{ | |
| 2627 | .owner = owner, | ||
| 2628 | .hook_epoch = epoch, | ||
| 2629 | .member = XInputRouteMember::Ex, | ||
| 2630 | 1272 | .user_index = static_cast<std::uint32_t>(user_index), | |
| 2631 | .state_address = reinterpret_cast<std::uintptr_t>(&ex_state), | ||
| 2632 | } | ||
| 2633 | ); | ||
| 2634 | } | ||
| 2635 | const bool admitted = | ||
| 2636 | 1273 | raw_scope.admitted() && primary_probe.admitted() && (!ex_required || ex_probe->admitted()); | |
| 2637 | 1273 | lock.unlock(); | |
| 2638 | 1273 | evidence.admitted = admitted; | |
| 2639 | 1273 | if (ex_required) | |
| 2640 | { | ||
| 2641 | 1272 | if (admitted) | |
| 2642 | 1270 | evidence.ex = observe_xinput_export(ex.target, static_cast<DWORD>(user_index), ex_state); | |
| 2643 | 1272 | evidence.ex.authenticated = ex_probe->finish(); | |
| 2644 | } | ||
| 2645 | 1273 | if (admitted) | |
| 2646 | 1271 | evidence.primary = observe_xinput_export(primary.target, static_cast<DWORD>(user_index), primary_state); | |
| 2647 | 1272 | evidence.primary.authenticated = primary_probe.finish(); | |
| 2648 | 1272 | evidence.primary.admitted = primary_probe.admitted(); | |
| 2649 | 1273 | evidence.ex.admitted = ex_required ? std::optional<bool>{ex_probe->admitted()} : std::nullopt; | |
| 2650 | 1273 | evidence.raw_admitted = raw_scope.admitted(); | |
| 2651 | 1273 | evidence.raw_restored = raw_scope.finish(); | |
| 2652 | 1273 | lock.relock(); | |
| 2653 | |||
| 2654 | // A successor owns its publication. An old receipt cannot revoke or refresh that owner's state. | ||
| 2655 | 2544 | if (s_intercept_owner.load(std::memory_order_acquire) != owner || | |
| 2656 | 1271 | s_xinput_hook_epoch.load(std::memory_order_seq_cst) != epoch) | |
| 2657 | 2 | return false; | |
| 2658 | 1271 | const bool unchanged = s_bound_user_index.load(std::memory_order_relaxed) == user_index && | |
| 2659 | 2542 | primary == capture_xinput_member(s_xinput_permanent_hooks->primary) && | |
| 2660 | 1271 | ex == capture_xinput_member(s_xinput_permanent_hooks->ex); | |
| 2661 | 1271 | evidence.unchanged = unchanged; | |
| 2662 | 1271 | evidence.healthy = xinput_consume_context_healthy(); | |
| 2663 | 2317 | const auto successful = [](const XInputCallEvidence &call) noexcept -> bool | |
| 2664 | { | ||
| 2665 | 4472 | return call.outcome == XInputCallOutcome::Returned && call.authenticated && | |
| 2666 | 4472 | call.result == ERROR_SUCCESS; | |
| 2667 | }; | ||
| 2668 | 1269 | if (admitted && successful(evidence.primary) && (!ex_required || successful(evidence.ex)) && | |
| 2669 | 2540 | *evidence.raw_restored && unchanged && *evidence.healthy) | |
| 2670 | { | ||
| 2671 | 993 | recovered = s_xinput_route_warned; | |
| 2672 | 993 | publish_complete_xinput_pair(user_index, owner); | |
| 2673 | 993 | s_xinput_observed_pair = true; | |
| 2674 | 993 | s_xinput_primary_observed = true; | |
| 2675 | 993 | s_xinput_ex_observed = ex_required; | |
| 2676 | 993 | return true; | |
| 2677 | } | ||
| 2678 | 278 | degrade_observed_xinput_pair(was_complete, user_index, primary, ex, evidence, lock); | |
| 2679 | 278 | return false; | |
| 2680 | 1273 | } | |
| 2681 | } // anonymous namespace | ||
| 2682 | |||
| 2683 | 1907 | bool install_xinput(int user_index, std::uint64_t owner) noexcept | |
| 2684 | { | ||
| 2685 | 1907 | InterceptLockGuard lock{s_intercept_mutex}; | |
| 2686 | 1907 | XInputRetentionLog deferred_log; | |
| 2687 | |||
| 2688 | 1907 | if (!owner_available(owner)) | |
| 2689 | { | ||
| 2690 | 34 | return false; | |
| 2691 | } | ||
| 2692 | |||
| 2693 | 3672 | if (s_xinput_permanent_detour.load(std::memory_order_acquire) || | |
| 2694 | 1799 | (s_xinput_permanent_hooks != nullptr && static_cast<bool>(s_xinput_permanent_hooks->primary))) | |
| 2695 | { | ||
| 2696 | 1660 | const bool was_complete = s_xinput_installed.load(std::memory_order_acquire); | |
| 2697 | 1660 | const std::uint64_t episode_epoch = s_xinput_hook_epoch.load(std::memory_order_seq_cst); | |
| 2698 | 269 | const bool reported_episode = s_xinput_route_warned && | |
| 2699 | 2198 | s_intercept_owner.load(std::memory_order_acquire) == owner && | |
| 2700 | 269 | s_bound_user_index.load(std::memory_order_relaxed) == user_index; | |
| 2701 | 1660 | const bool exact = s_xinput_permanent_detour.load(std::memory_order_acquire) | |
| 2702 | 1660 | ? maintain_retained_xinput_pair(user_index, owner) | |
| 2703 | 1660 | : maintain_live_xinput_pair(user_index, owner); | |
| 2704 | 1660 | bool recovered = false; | |
| 2705 | 1660 | const bool complete = exact || observe_xinput_pair(user_index, owner, was_complete, recovered, lock); | |
| 2706 | 1660 | if (!complete) | |
| 2707 | 420 | return false; | |
| 2708 | 1240 | if (exact) | |
| 2709 | { | ||
| 2710 | 297 | recovered = reported_episode && s_xinput_hook_epoch.load(std::memory_order_seq_cst) == episode_epoch && | |
| 2711 | 2 | s_intercept_owner.load(std::memory_order_acquire) == owner; | |
| 2712 | } | ||
| 2713 | 1240 | lock.unlock(); | |
| 2714 | 1240 | if (recovered) | |
| 2715 | { | ||
| 2716 | 29 | std::array<char, 128> message{}; | |
| 2717 | 29 | std::size_t length = 0; | |
| 2718 | 29 | append_text(message, length, "InputIntercept: XInput paired suppression recovered: controller="); | |
| 2719 | 29 | append_decimal(message, length, static_cast<std::uint32_t>(user_index)); | |
| 2720 | 29 | append_text(message, length, exact ? ", authorization=exact." : ", authorization=observed."); | |
| 2721 | 58 | (void)log().log_noexcept(LogLevel::Info, std::string_view{message.data(), length}); | |
| 2722 | } | ||
| 2723 | 1240 | return true; | |
| 2724 | } | ||
| 2725 | |||
| 2726 | 213 | const HMODULE module = find_loaded_xinput_module(); | |
| 2727 | 213 | if (module == nullptr) | |
| 2728 | { | ||
| 2729 | ✗ | return false; // XInput is not loaded yet. The poll loop retries. | |
| 2730 | } | ||
| 2731 | |||
| 2732 | 213 | auto *get_state = reinterpret_cast<void *>(GetProcAddress(module, "XInputGetState")); | |
| 2733 | 213 | if (get_state == nullptr) | |
| 2734 | { | ||
| 2735 | 1 | return false; | |
| 2736 | } | ||
| 2737 | |||
| 2738 | // XInputGetStateEx (ordinal 100) carries the Guide button. Without its hook, a game can bypass the mask. | ||
| 2739 | // An absent export needs no second hook. The primary route already covers an alias. | ||
| 2740 | auto *get_state_ex = | ||
| 2741 | 212 | reinterpret_cast<void *>(GetProcAddress(module, MAKEINTRESOURCEA(XINPUT_GET_STATE_EX_ORDINAL))); | |
| 2742 | 212 | const bool ex_is_distinct_member = get_state_ex != nullptr && get_state_ex != get_state; | |
| 2743 | |||
| 2744 | // Construct the canonical cell before any reference or target patch. | ||
| 2745 | 212 | if (!ensure_permanent_cell()) | |
| 2746 | { | ||
| 2747 | ✗ | return false; | |
| 2748 | } | ||
| 2749 | |||
| 2750 | 212 | if (!acquire_xinput_keepalives(get_state, get_state_ex, ex_is_distinct_member)) | |
| 2751 | { | ||
| 2752 | ✗ | return false; | |
| 2753 | } | ||
| 2754 | |||
| 2755 | 212 | if (!reserve_xinput_route_probe() || !acquire_xinput_raw_scope_owner()) | |
| 2756 | { | ||
| 2757 | ✗ | retire_xinput_module_refs(); | |
| 2758 | ✗ | return false; | |
| 2759 | } | ||
| 2760 | 212 | s_xinput_raw_owner = true; | |
| 2761 | |||
| 2762 | // Reserve the worst case for BOTH members before either creation. A primary charge followed by an Ex refusal | ||
| 2763 | // strands the primary-only coverage that this transaction prevents. | ||
| 2764 | 212 | safetyhook::RouteRetentionCredit pair_credit = safetyhook::RouteRetentionCredit::acquire(2); | |
| 2765 | 212 | if (!pair_credit) | |
| 2766 | { | ||
| 2767 | 1 | retire_xinput_install_after_credit_refusal(); | |
| 2768 | 1 | return false; | |
| 2769 | } | ||
| 2770 | |||
| 2771 | // Create both members before either prologue patch. Before an arm runs, no thread enters a detour. A creation | ||
| 2772 | // failure rolls the whole transaction back and leaves both entries open. | ||
| 2773 | 211 | if (!create_disabled_xinput_hook( | |
| 2774 | pair_credit, | ||
| 2775 | get_state, | ||
| 2776 | reinterpret_cast<void *>(&xinput_get_state_detour), | ||
| 2777 | XINPUT_GET_STATE_POSITION, | ||
| 2778 | 211 | s_xinput_permanent_hooks->primary, | |
| 2779 | 211 | s_xinput_permanent_hooks->primary_gateway | |
| 2780 | )) | ||
| 2781 | { | ||
| 2782 | 2 | retire_xinput_module_refs(); | |
| 2783 | 2 | return false; | |
| 2784 | } | ||
| 2785 | 400 | if (ex_is_distinct_member && !create_disabled_xinput_hook( | |
| 2786 | pair_credit, | ||
| 2787 | get_state_ex, | ||
| 2788 | reinterpret_cast<void *>(&xinput_get_state_ex_detour), | ||
| 2789 | XINPUT_GET_STATE_EX_POSITION, | ||
| 2790 | 191 | s_xinput_permanent_hooks->ex, | |
| 2791 | 191 | s_xinput_permanent_hooks->ex_gateway | |
| 2792 | )) | ||
| 2793 | { | ||
| 2794 | const char *const primary_retained = | ||
| 2795 | ✗ | reset_inactive_xinput_hook(s_xinput_permanent_hooks->primary, s_xinput_original); | |
| 2796 | ✗ | retire_xinput_module_refs(reset_chain_reach(primary_retained != nullptr)); | |
| 2797 | ✗ | lock.unlock(); | |
| 2798 | ✗ | emit_xinput_route_retention_logs(primary_retained, nullptr); | |
| 2799 | ✗ | return false; | |
| 2800 | } | ||
| 2801 | |||
| 2802 | 418 | const XInputArmOutcome primary_outcome = arm_created_xinput_hook( | |
| 2803 | 209 | s_xinput_permanent_hooks->primary, | |
| 2804 | s_xinput_original, | ||
| 2805 | s_xinput_enable_warned, | ||
| 2806 | 209 | "InputIntercept: XInputGetState hook transaction did not complete cleanly; state was " | |
| 2807 | "reconciled from the target bytes." | ||
| 2808 | ); | ||
| 2809 | 209 | if (primary_outcome == XInputArmOutcome::CommittedUnreachable) | |
| 2810 | { | ||
| 2811 | 1 | retain_unproved_xinput_install(deferred_log); | |
| 2812 | 1 | lock.unlock(); | |
| 2813 | 1 | emit_xinput_retention_log(deferred_log); | |
| 2814 | 1 | return false; | |
| 2815 | } | ||
| 2816 | 208 | if (primary_outcome != XInputArmOutcome::Armed) | |
| 2817 | { | ||
| 2818 | const char *const ex_retained = | ||
| 2819 | 2 | reset_inactive_xinput_hook(s_xinput_permanent_hooks->ex, s_xinput_ex_original); | |
| 2820 | const char *const primary_retained = | ||
| 2821 | 2 | reset_inactive_xinput_hook(s_xinput_permanent_hooks->primary, s_xinput_original); | |
| 2822 | 2 | retire_xinput_module_refs(reset_chain_reach(primary_retained || ex_retained)); | |
| 2823 | 2 | lock.unlock(); | |
| 2824 | 2 | emit_xinput_route_retention_logs(primary_retained, ex_retained); | |
| 2825 | 2 | return false; | |
| 2826 | } | ||
| 2827 | |||
| 2828 | 206 | bool recovered = false; | |
| 2829 | 255 | return arm_ex_member_and_publish_xinput_pair(ex_is_distinct_member, user_index, owner) || | |
| 2830 | 255 | observe_xinput_pair(user_index, owner, false, recovered, lock); | |
| 2831 | 1907 | } | |
| 2832 | |||
| 2833 | 10234 | bool xinput_installed() noexcept | |
| 2834 | { | ||
| 2835 | 10234 | return s_xinput_installed.load(std::memory_order_acquire) && xinput_consume_context_healthy(); | |
| 2836 | } | ||
| 2837 | |||
| 2838 | 87 | XInputGetStateFn xinput_trampoline() noexcept | |
| 2839 | { | ||
| 2840 | 109 | if (!s_xinput_installed.load(std::memory_order_acquire) && | |
| 2841 | 22 | !s_xinput_pair_degraded.load(std::memory_order_acquire)) | |
| 2842 | { | ||
| 2843 | 16 | return nullptr; | |
| 2844 | } | ||
| 2845 | 71 | return s_xinput_original.load(std::memory_order_acquire); | |
| 2846 | } | ||
| 2847 | |||
| 2848 | 1652 | bool sample_xinput_raw_state(DWORD user_index, XINPUT_STATE &state, std::uint64_t owner) noexcept | |
| 2849 | { | ||
| 2850 | 1652 | XInputRawScope raw_scope; | |
| 2851 | 1652 | if (!raw_scope.admitted()) | |
| 2852 | ✗ | return false; | |
| 2853 | 1652 | XInputGetStateFn target = nullptr; | |
| 2854 | { | ||
| 2855 | 1652 | const InterceptLockGuard lock{s_intercept_mutex}; | |
| 2856 | 2691 | if (s_xinput_permanent_hooks != nullptr && s_xinput_permanent_hooks->primary && | |
| 2857 | 1039 | s_intercept_owner.load(std::memory_order_acquire) == owner) | |
| 2858 | { | ||
| 2859 | 1039 | target = reinterpret_cast<XInputGetStateFn>(s_xinput_permanent_hooks->primary.target()); | |
| 2860 | } | ||
| 2861 | 613 | else if (const HMODULE module = find_loaded_xinput_module(); module != nullptr) | |
| 2862 | { | ||
| 2863 | target = reinterpret_cast<XInputGetStateFn>( | ||
| 2864 | 613 | reinterpret_cast<void (*)()>(GetProcAddress(module, "XInputGetState")) | |
| 2865 | ); | ||
| 2866 | } | ||
| 2867 | 1652 | } | |
| 2868 | 1652 | const DWORD result = invoke_xinput_export(target != nullptr ? target : &XInputGetState, user_index, state); | |
| 2869 | 1652 | const bool restored = raw_scope.finish(); | |
| 2870 | 1652 | return result == ERROR_SUCCESS && restored; | |
| 2871 | 1652 | } | |
| 2872 | |||
| 2873 | 1152 | bool publish_gamepad_suppress(uint16_t suppress_bits, std::uint64_t owner) noexcept | |
| 2874 | { | ||
| 2875 | 1152 | run_data_plane_entry_seam(); | |
| 2876 | 1152 | const DataPlaneLockGuard data_lock; | |
| 2877 | 1152 | if (!data_plane_authorized(owner)) | |
| 2878 | { | ||
| 2879 | 4 | return false; | |
| 2880 | } | ||
| 2881 | // Write the deadline before the release store on the mask, so a fresh mask is never paired with a stale | ||
| 2882 | // deadline. | ||
| 2883 | 1148 | s_suppress_deadline_ms.store(GetTickCount64() + SUPPRESS_TTL_MS, std::memory_order_relaxed); | |
| 2884 | 1148 | s_suppress_mask.store(suppress_bits, std::memory_order_release); | |
| 2885 | 1148 | return true; | |
| 2886 | 1152 | } | |
| 2887 | |||
| 2888 | namespace | ||
| 2889 | { | ||
| 2890 | /** | ||
| 2891 | * @brief Removes the mounted hook before the new hook mounts. | ||
| 2892 | * @details The transaction disables capture and consume, advances the epoch, and drains the wheel admitted | ||
| 2893 | * phases before it removes the old hook. Hooks never overlap. | ||
| 2894 | * @return False if the new mount must not proceed. A false return leaves the route state Retryable or | ||
| 2895 | * CleanupBlocked. | ||
| 2896 | * @note Requires s_intercept_mutex. | ||
| 2897 | */ | ||
| 2898 | 5 | [[nodiscard]] bool unmount_message_hook_for_migration(HHOOK mounted) noexcept | |
| 2899 | { | ||
| 2900 | 5 | const std::uint64_t wheel_epoch = close_wheel_capture_and_advance_epoch(); | |
| 2901 | { | ||
| 2902 | 5 | const DataPlaneLockGuard data_lock; | |
| 2903 | 5 | reset_wheel_data_plane(wheel_epoch); | |
| 2904 | 5 | } | |
| 2905 | 5 | if (!drain_wheel_admitted_phases()) | |
| 2906 | { | ||
| 2907 | s_msg_hook_route_state.store( | ||
| 2908 | static_cast<std::uint8_t>(WheelRouteState::Retryable), | ||
| 2909 | std::memory_order_release | ||
| 2910 | ); | ||
| 2911 | 1 | return false; | |
| 2912 | } | ||
| 2913 | 4 | if (msg_hook_target_thread_exited_locked()) | |
| 2914 | { | ||
| 2915 | ✗ | retire_message_hook_route_locked(); | |
| 2916 | } | ||
| 2917 | 4 | else if (!unhook_or_already_gone(mounted)) | |
| 2918 | { | ||
| 2919 | // Old-hook removal failed on a live thread. Block the new mount until that thread exits. | ||
| 2920 | s_msg_hook_route_state.store( | ||
| 2921 | static_cast<std::uint8_t>(WheelRouteState::CleanupBlocked), | ||
| 2922 | std::memory_order_release | ||
| 2923 | ); | ||
| 2924 | 1 | return false; | |
| 2925 | } | ||
| 2926 | else | ||
| 2927 | { | ||
| 2928 | 3 | s_msg_hook.store(nullptr, std::memory_order_release); | |
| 2929 | s_msg_hook_thread_id.store(0, std::memory_order_release); | ||
| 2930 | 3 | if (s_msg_hook_thread != nullptr) | |
| 2931 | { | ||
| 2932 | 3 | CloseHandle(s_msg_hook_thread); | |
| 2933 | 3 | s_msg_hook_thread = nullptr; | |
| 2934 | } | ||
| 2935 | } | ||
| 2936 | 3 | return true; | |
| 2937 | } | ||
| 2938 | } // anonymous namespace | ||
| 2939 | |||
| 2940 | 40 | bool install_message_hook(std::uint64_t owner, std::uint32_t target_thread_id) noexcept | |
| 2941 | { | ||
| 2942 | 40 | const InterceptLockGuard lock{s_intercept_mutex}; | |
| 2943 | 40 | if (!owner_available(owner) || target_thread_id == 0) | |
| 2944 | { | ||
| 2945 | ✗ | return false; | |
| 2946 | } | ||
| 2947 | 40 | settle_message_hook_route_locked(); | |
| 2948 | 40 | if (s_msg_hook_route_state.load(std::memory_order_relaxed) == | |
| 2949 | static_cast<std::uint8_t>(WheelRouteState::CleanupBlocked)) | ||
| 2950 | { | ||
| 2951 | // A prior removal failure still blocks new mounts. settle_message_hook_route_locked() clears this once | ||
| 2952 | // the blocked thread exits. | ||
| 2953 | 1 | return false; | |
| 2954 | } | ||
| 2955 | |||
| 2956 | 39 | const HHOOK mounted = s_msg_hook.load(std::memory_order_relaxed); | |
| 2957 | 39 | if (mounted != nullptr) | |
| 2958 | { | ||
| 2959 | 9 | if (s_msg_hook_thread_id.load(std::memory_order_relaxed) == target_thread_id && | |
| 2960 | 2 | s_msg_hook_route_state.load(std::memory_order_relaxed) == | |
| 2961 | static_cast<std::uint8_t>(WheelRouteState::Ready)) | ||
| 2962 | { | ||
| 2963 | // A same-thread mount is idempotent. A window change on the same thread needs no republish. | ||
| 2964 | 2 | publish_owner(owner); | |
| 2965 | 2 | return true; | |
| 2966 | } | ||
| 2967 | 5 | if (!unmount_message_hook_for_migration(mounted)) | |
| 2968 | { | ||
| 2969 | 2 | return false; | |
| 2970 | } | ||
| 2971 | } | ||
| 2972 | // Validate the target: it must belong to this process and be alive. | ||
| 2973 | 35 | HANDLE thread = OpenThread(SYNCHRONIZE | THREAD_QUERY_LIMITED_INFORMATION, FALSE, target_thread_id); | |
| 2974 | 35 | if (thread == nullptr) | |
| 2975 | { | ||
| 2976 | s_msg_hook_route_state.store( | ||
| 2977 | static_cast<std::uint8_t>(WheelRouteState::Retryable), | ||
| 2978 | std::memory_order_release | ||
| 2979 | ); | ||
| 2980 | 1 | return false; | |
| 2981 | } | ||
| 2982 | 34 | if (GetProcessIdOfThread(thread) != GetCurrentProcessId() || WaitForSingleObject(thread, 0) == WAIT_OBJECT_0) | |
| 2983 | { | ||
| 2984 | ✗ | CloseHandle(thread); | |
| 2985 | s_msg_hook_route_state.store( | ||
| 2986 | static_cast<std::uint8_t>(WheelRouteState::Retryable), | ||
| 2987 | std::memory_order_release | ||
| 2988 | ); | ||
| 2989 | ✗ | return false; | |
| 2990 | } | ||
| 2991 | |||
| 2992 | // Take the keepalive before the hook is published. A successful publication makes it permanent because a | ||
| 2993 | // selected callback can run after UnhookWindowsHookEx returns. A failed publication releases it. | ||
| 2994 | 34 | HMODULE new_ref = nullptr; | |
| 2995 | 34 | if (!s_msg_hook_ref_taken.load(std::memory_order_relaxed)) | |
| 2996 | { | ||
| 2997 | 26 | new_ref = acquire_module_ref(diagnostics::ModulePinReason::MessageHookKeepalive); | |
| 2998 | 26 | if (new_ref == nullptr) | |
| 2999 | { | ||
| 3000 | ✗ | CloseHandle(thread); | |
| 3001 | ✗ | return false; | |
| 3002 | } | ||
| 3003 | } | ||
| 3004 | |||
| 3005 | 34 | const HHOOK hook = SetWindowsHookExW(WH_GETMESSAGE, &message_hook_proc, nullptr, target_thread_id); | |
| 3006 | 34 | if (hook == nullptr) | |
| 3007 | { | ||
| 3008 | ✗ | release_module_ref(new_ref, diagnostics::ModulePinReason::MessageHookKeepalive); | |
| 3009 | ✗ | CloseHandle(thread); | |
| 3010 | s_msg_hook_route_state.store( | ||
| 3011 | static_cast<std::uint8_t>(WheelRouteState::Retryable), | ||
| 3012 | std::memory_order_release | ||
| 3013 | ); | ||
| 3014 | ✗ | return false; | |
| 3015 | } | ||
| 3016 | 34 | if (new_ref != nullptr) | |
| 3017 | { | ||
| 3018 | 26 | s_msg_hook_ref_taken.store(true, std::memory_order_relaxed); | |
| 3019 | 26 | DetourModKit::diagnostics::record_intentional_leak(DetourModKit::diagnostics::LeakSubsystem::Input); | |
| 3020 | } | ||
| 3021 | 34 | s_msg_hook_thread = thread; | |
| 3022 | // A callback that races route publication passes through until this id becomes visible. | ||
| 3023 | s_msg_hook_thread_id.store(target_thread_id, std::memory_order_release); | ||
| 3024 | 34 | s_msg_hook.store(hook, std::memory_order_release); | |
| 3025 | 34 | s_msg_hook_mount_generation = s_msg_hook_mount_generation + 1 == 0 ? 1 : s_msg_hook_mount_generation + 1; | |
| 3026 | s_msg_hook_route_state.store(static_cast<std::uint8_t>(WheelRouteState::Ready), std::memory_order_release); | ||
| 3027 | 34 | publish_owner(owner); | |
| 3028 | 34 | return true; | |
| 3029 | 40 | } | |
| 3030 | |||
| 3031 | 9779 | bool message_hook_installed() noexcept | |
| 3032 | { | ||
| 3033 | 9779 | return message_hook_route_state() == WheelRouteState::Ready; | |
| 3034 | } | ||
| 3035 | |||
| 3036 | 9827 | WheelRouteState message_hook_route_state() noexcept | |
| 3037 | { | ||
| 3038 | 9827 | const InterceptLockGuard lock{s_intercept_mutex}; | |
| 3039 | 9827 | settle_message_hook_route_locked(); | |
| 3040 | 9827 | return static_cast<WheelRouteState>(s_msg_hook_route_state.load(std::memory_order_relaxed)); | |
| 3041 | 9827 | } | |
| 3042 | |||
| 3043 | 29 | std::uint32_t message_hook_thread_id() noexcept | |
| 3044 | { | ||
| 3045 | 29 | return s_msg_hook_thread_id.load(std::memory_order_acquire); | |
| 3046 | } | ||
| 3047 | |||
| 3048 | 6 | std::uint64_t message_hook_mount_generation() noexcept | |
| 3049 | { | ||
| 3050 | 6 | const InterceptLockGuard lock{s_intercept_mutex}; | |
| 3051 | 12 | return s_msg_hook_mount_generation; | |
| 3052 | 6 | } | |
| 3053 | |||
| 3054 | 112 | std::array<int, 4> take_wheel_counts(std::uint64_t owner) noexcept | |
| 3055 | { | ||
| 3056 | 112 | run_data_plane_entry_seam(); | |
| 3057 | 112 | const DataPlaneLockGuard data_lock; | |
| 3058 | 112 | std::array<int, 4> out{}; | |
| 3059 | 112 | if (!data_plane_authorized(owner)) | |
| 3060 | { | ||
| 3061 | 12 | return out; | |
| 3062 | } | ||
| 3063 | 100 | const std::uint64_t wheel_epoch = wheel_capture_epoch(s_wheel_capture_state.load(std::memory_order_seq_cst)); | |
| 3064 | 500 | for (int dir = 0; dir < 4; ++dir) | |
| 3065 | { | ||
| 3066 | 400 | const std::uint64_t packed = s_wheel_count[static_cast<size_t>(dir)].exchange( | |
| 3067 | wheel_count_slot(wheel_epoch, 0), | ||
| 3068 | std::memory_order_relaxed | ||
| 3069 | ); | ||
| 3070 | 400 | if (wheel_slot_epoch(packed) == wheel_epoch) | |
| 3071 | { | ||
| 3072 | 400 | out[static_cast<size_t>(dir)] = static_cast<int>(packed & WHEEL_COUNT_MASK); | |
| 3073 | } | ||
| 3074 | } | ||
| 3075 | 100 | return out; | |
| 3076 | 112 | } | |
| 3077 | |||
| 3078 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 3079 | 4 | void seed_wheel_notches_for_test(const std::array<int, 4> ¬ches) noexcept | |
| 3080 | { | ||
| 3081 | 4 | const std::uint64_t wheel_epoch = wheel_capture_epoch(s_wheel_capture_state.load(std::memory_order_seq_cst)); | |
| 3082 | 40 | for (size_t dir = 0; dir < s_wheel_count.size(); ++dir) | |
| 3083 | { | ||
| 3084 | // Saturate to bump_wheel_notch's ceiling so a seeded backlog stays a producible state. | ||
| 3085 | 16 | int n = notches[dir]; | |
| 3086 | 16 | if (n < 0) | |
| 3087 | { | ||
| 3088 | ✗ | n = 0; | |
| 3089 | } | ||
| 3090 | 16 | else if (n > MAX_WHEEL_NOTCHES) | |
| 3091 | { | ||
| 3092 | ✗ | n = MAX_WHEEL_NOTCHES; | |
| 3093 | } | ||
| 3094 | 16 | s_wheel_count[dir].store( | |
| 3095 | wheel_count_slot(wheel_epoch, static_cast<std::uint64_t>(n)), | ||
| 3096 | std::memory_order_relaxed | ||
| 3097 | ); | ||
| 3098 | } | ||
| 3099 | 4 | } | |
| 3100 | #endif | ||
| 3101 | |||
| 3102 | 1476 | bool publish_wheel_consume(uint8_t direction_mask, bool require_focus, std::uint64_t owner) noexcept | |
| 3103 | { | ||
| 3104 | 1476 | run_data_plane_entry_seam(); | |
| 3105 | 1476 | const DataPlaneLockGuard data_lock; | |
| 3106 | 1476 | if (!data_plane_authorized(owner)) | |
| 3107 | { | ||
| 3108 | 851 | return false; | |
| 3109 | } | ||
| 3110 | 625 | if ((s_wheel_capture_state.load(std::memory_order_seq_cst) & WHEEL_CAPTURE_ENABLED) == 0) | |
| 3111 | { | ||
| 3112 | 3 | return false; | |
| 3113 | } | ||
| 3114 | 622 | s_wheel_require_focus.store(require_focus, std::memory_order_relaxed); | |
| 3115 | // Refresh the deadline before the mask release store, but only for a nonzero arm. This order ensures a set | ||
| 3116 | // direction bit never appears with a stale deadline. A zero mask needs no deadline. | ||
| 3117 | 622 | if (direction_mask != 0) | |
| 3118 | { | ||
| 3119 | 22 | s_wheel_consume_deadline_ms.store(GetTickCount64() + SUPPRESS_TTL_MS, std::memory_order_relaxed); | |
| 3120 | } | ||
| 3121 | 622 | s_wheel_consume_mask.store(direction_mask, std::memory_order_release); | |
| 3122 | 622 | return true; | |
| 3123 | 1476 | } | |
| 3124 | |||
| 3125 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 3126 | 12 | void set_xinput_arm_seam(XInputArmSeam seam) noexcept | |
| 3127 | { | ||
| 3128 | 12 | s_xinput_arm_seam.store(seam, std::memory_order_release); | |
| 3129 | 12 | } | |
| 3130 | |||
| 3131 | 19 | void set_xinput_detour_body_seam(XInputDetourBodySeam seam) noexcept | |
| 3132 | { | ||
| 3133 | 19 | s_xinput_detour_body_seam.store(seam, std::memory_order_release); | |
| 3134 | 19 | } | |
| 3135 | |||
| 3136 | 4 | void set_xinput_route_entry_hold_for_test(bool hold) noexcept | |
| 3137 | { | ||
| 3138 | 4 | safetyhook::set_route_park_for_test( | |
| 3139 | hold ? safetyhook::RouteParkStage::BEFORE_DESTINATION : safetyhook::RouteParkStage::NONE | ||
| 3140 | ); | ||
| 3141 | 4 | } | |
| 3142 | |||
| 3143 | 2077 | bool xinput_route_entry_reached_for_test() noexcept | |
| 3144 | { | ||
| 3145 | 2077 | return safetyhook::route_park_reached_for_test(); | |
| 3146 | } | ||
| 3147 | |||
| 3148 | 18 | void set_xinput_clean_release_seam(XInputCleanReleaseSeam seam) noexcept | |
| 3149 | { | ||
| 3150 | 18 | s_xinput_clean_release_seam.store(seam, std::memory_order_release); | |
| 3151 | 18 | } | |
| 3152 | |||
| 3153 | 2 | void set_xinput_retention_attribution_seam(XInputRetentionAttributionSeam seam) noexcept | |
| 3154 | { | ||
| 3155 | 2 | s_xinput_retention_attribution_seam.store(seam, std::memory_order_release); | |
| 3156 | 2 | } | |
| 3157 | |||
| 3158 | 2 | void set_xinput_create_seam(XInputCreateSeam seam) noexcept | |
| 3159 | { | ||
| 3160 | 2 | s_xinput_create_seam.store(seam, std::memory_order_release); | |
| 3161 | 2 | } | |
| 3162 | |||
| 3163 | 30 | void set_xinput_backend_toggle_exception_for_test(void *target, bool after_mutation) noexcept | |
| 3164 | { | ||
| 3165 | 30 | if (target == nullptr) | |
| 3166 | { | ||
| 3167 | 18 | safetyhook::g_trap_exception_stage_override.store( | |
| 3168 | safetyhook::TrapExceptionStage::NONE, | ||
| 3169 | std::memory_order_release | ||
| 3170 | ); | ||
| 3171 | 18 | safetyhook::g_trap_exception_target_override.store(nullptr, std::memory_order_relaxed); | |
| 3172 | 18 | return; | |
| 3173 | } | ||
| 3174 | |||
| 3175 | s_xinput_backend_toggle_exception_catches.store(0, std::memory_order_relaxed); | ||
| 3176 | 12 | safetyhook::g_trap_exception_target_override.store( | |
| 3177 | static_cast<std::uint8_t *>(target), | ||
| 3178 | std::memory_order_relaxed | ||
| 3179 | ); | ||
| 3180 | 12 | safetyhook::g_trap_exception_stage_override.store( | |
| 3181 | after_mutation ? safetyhook::TrapExceptionStage::AFTER_MUTATION | ||
| 3182 | : safetyhook::TrapExceptionStage::BEFORE_MUTATION, | ||
| 3183 | std::memory_order_release | ||
| 3184 | ); | ||
| 3185 | } | ||
| 3186 | |||
| 3187 | 8 | std::size_t xinput_backend_toggle_exception_catches_for_test() noexcept | |
| 3188 | { | ||
| 3189 | 8 | return s_xinput_backend_toggle_exception_catches.load(std::memory_order_relaxed); | |
| 3190 | } | ||
| 3191 | |||
| 3192 | 12 | bool xinput_permanent_primary_retained() noexcept | |
| 3193 | { | ||
| 3194 | 17 | return s_xinput_permanent_detour.load(std::memory_order_acquire) && s_xinput_permanent_hooks != nullptr && | |
| 3195 | 17 | static_cast<bool>(s_xinput_permanent_hooks->primary); | |
| 3196 | } | ||
| 3197 | |||
| 3198 | 127 | int xinput_module_refs_held() noexcept | |
| 3199 | { | ||
| 3200 | 127 | return s_xinput_permanent_hooks != nullptr ? (s_xinput_permanent_hooks->self_ref != nullptr ? 1 : 0) + | |
| 3201 | 126 | (s_xinput_permanent_hooks->target_ref != nullptr ? 1 : 0) + | |
| 3202 | 126 | (s_xinput_permanent_hooks->ex_target_ref != nullptr ? 1 : 0) | |
| 3203 | 127 | : 0; | |
| 3204 | } | ||
| 3205 | |||
| 3206 | 1 | void arm_xinput_process_exit_oracle_for_test(const std::uint8_t *target) noexcept | |
| 3207 | { | ||
| 3208 | 1 | if (target == nullptr) | |
| 3209 | { | ||
| 3210 | ✗ | return; | |
| 3211 | } | ||
| 3212 | 17 | for (std::size_t i = 0; i < XINPUT_PROCESS_EXIT_WITNESS_BYTES; ++i) | |
| 3213 | { | ||
| 3214 | 16 | s_xinput_process_exit_patch[i] = target[i]; | |
| 3215 | } | ||
| 3216 | 1 | s_xinput_process_exit_target.store(target, std::memory_order_release); | |
| 3217 | } | ||
| 3218 | |||
| 3219 | 1735 | void set_xinput_module_override_for_test(HMODULE module) noexcept | |
| 3220 | { | ||
| 3221 | 1735 | const InterceptLockGuard lock{s_intercept_mutex}; | |
| 3222 | 1735 | s_xinput_module_override = module; | |
| 3223 | 1735 | } | |
| 3224 | |||
| 3225 | 62 | XInputGetStateFn xinput_ex_trampoline() noexcept | |
| 3226 | { | ||
| 3227 | 62 | return s_xinput_ex_original.load(std::memory_order_acquire); | |
| 3228 | } | ||
| 3229 | |||
| 3230 | 12 | void apply_xinput_suppress_for_test(XINPUT_STATE *state, DWORD user_index) noexcept | |
| 3231 | { | ||
| 3232 | 12 | if (state != nullptr) | |
| 3233 | 12 | state->Gamepad.wButtons = suppressed_xinput_buttons(state, user_index, state->Gamepad.wButtons); | |
| 3234 | 12 | } | |
| 3235 | |||
| 3236 | 4 | int xinput_bound_user_index() noexcept | |
| 3237 | { | ||
| 3238 | 4 | return s_bound_user_index.load(std::memory_order_relaxed); | |
| 3239 | } | ||
| 3240 | |||
| 3241 | 12 | void set_data_plane_entry_seam(DataPlaneEntrySeam seam) noexcept | |
| 3242 | { | ||
| 3243 | 12 | s_data_plane_entry_seam.store(seam, std::memory_order_release); | |
| 3244 | 12 | } | |
| 3245 | |||
| 3246 | 7 | void set_wheel_capture_entry_seam(WheelCaptureEntrySeam seam) noexcept | |
| 3247 | { | ||
| 3248 | 7 | s_wheel_capture_entry_seam.store(seam, std::memory_order_release); | |
| 3249 | 7 | } | |
| 3250 | |||
| 3251 | 36 | void set_wheel_finalize_entry_seam(WheelFinalizeEntrySeam seam) noexcept | |
| 3252 | { | ||
| 3253 | 36 | s_wheel_finalize_entry_seam.store(seam, std::memory_order_release); | |
| 3254 | 36 | } | |
| 3255 | |||
| 3256 | 5 | void set_wheel_drain_timeout_for_test(std::uint64_t timeout_ms) noexcept | |
| 3257 | { | ||
| 3258 | s_wheel_drain_timeout_override_ms.store(timeout_ms, std::memory_order_release); | ||
| 3259 | 5 | } | |
| 3260 | |||
| 3261 | 38 | void set_message_unhook_failure_for_test(bool fail) noexcept | |
| 3262 | { | ||
| 3263 | 38 | s_force_message_unhook_failure.store(fail, std::memory_order_release); | |
| 3264 | 38 | } | |
| 3265 | |||
| 3266 | 38 | void set_wheel_process_focus_for_test(std::int32_t focused) noexcept | |
| 3267 | { | ||
| 3268 | s_wheel_process_focus_override.store(focused, std::memory_order_release); | ||
| 3269 | 38 | } | |
| 3270 | |||
| 3271 | 2 | std::uint32_t wheel_admitted_phases_for_test() noexcept | |
| 3272 | { | ||
| 3273 | 2 | return s_wheel_admitted_phases.load(std::memory_order_seq_cst); | |
| 3274 | } | ||
| 3275 | |||
| 3276 | 32 | bool process_wheel_message_for_test(bool horizontal, int delta) noexcept | |
| 3277 | { | ||
| 3278 | 32 | const std::uint64_t capture_state = s_wheel_capture_state.load(std::memory_order_seq_cst); | |
| 3279 | 32 | const bool consume_intent = wheel_count_admission(horizontal, delta, capture_state); | |
| 3280 | 32 | return consume_intent && wheel_consume_finalization(horizontal, delta, capture_state); | |
| 3281 | } | ||
| 3282 | |||
| 3283 | 2 | std::uint32_t consume_rules_sequence() noexcept | |
| 3284 | { | ||
| 3285 | 2 | return s_consume_rules_seq.load(std::memory_order_acquire); | |
| 3286 | } | ||
| 3287 | |||
| 3288 | 1 | std::uint16_t gamepad_suppress_mask_for_test() noexcept | |
| 3289 | { | ||
| 3290 | 1 | return s_suppress_mask.load(std::memory_order_acquire); | |
| 3291 | } | ||
| 3292 | |||
| 3293 | 1 | bool gamepad_rule_suppress_enabled_for_test() noexcept | |
| 3294 | { | ||
| 3295 | 1 | return s_rule_suppress_enabled.load(std::memory_order_acquire); | |
| 3296 | } | ||
| 3297 | |||
| 3298 | 2 | std::uint8_t wheel_consume_mask_for_test() noexcept | |
| 3299 | { | ||
| 3300 | 2 | return s_wheel_consume_mask.load(std::memory_order_acquire); | |
| 3301 | } | ||
| 3302 | #endif | ||
| 3303 | |||
| 3304 | namespace | ||
| 3305 | { | ||
| 3306 | /** | ||
| 3307 | * @brief Disarms the logical XInput layer and resets its warning latches. | ||
| 3308 | * @note Requires s_intercept_mutex. | ||
| 3309 | */ | ||
| 3310 | 207 | void clear_xinput_install_state() noexcept | |
| 3311 | { | ||
| 3312 | 207 | s_xinput_installed.store(false, std::memory_order_release); | |
| 3313 | 207 | s_xinput_pair_degraded.store(false, std::memory_order_release); | |
| 3314 | 207 | xinput_recovery_reset(); | |
| 3315 | 207 | s_xinput_enable_warned.store(false, std::memory_order_relaxed); | |
| 3316 | 207 | s_xinput_ex_enable_warned.store(false, std::memory_order_relaxed); | |
| 3317 | 207 | s_xinput_capacity_warned.store(false, std::memory_order_relaxed); | |
| 3318 | 207 | } | |
| 3319 | |||
| 3320 | /** | ||
| 3321 | * @brief Closes backend admission and retires the published trampoline pointers. | ||
| 3322 | * @return The chains published before the retirement. | ||
| 3323 | * @note Requires s_intercept_mutex and a constructed process-lifetime cell. | ||
| 3324 | */ | ||
| 3325 | 207 | [[nodiscard]] XInputPublishedChains retire_published_xinput_chains() noexcept | |
| 3326 | { | ||
| 3327 | // Close backend admission before pointer retirement. This covers the interval before InflightGuard and the | ||
| 3328 | // body it counts. | ||
| 3329 | 207 | s_xinput_permanent_hooks->ex.begin_route_rundown(); | |
| 3330 | 207 | s_xinput_permanent_hooks->primary.begin_route_rundown(); | |
| 3331 | |||
| 3332 | // Retire the published trampoline pointers before the drain in quiesce_xinput_routes_until_deadline(). A | ||
| 3333 | // late entrant sees nullptr instead of a pointer into a hook near destruction. seq_cst places these stores | ||
| 3334 | // and the drain load in the detour total order. | ||
| 3335 | const XInputPublishedChains published_chains{ | ||
| 3336 | 207 | s_xinput_original.load(std::memory_order_seq_cst) != nullptr, | |
| 3337 | 414 | s_xinput_ex_original.load(std::memory_order_seq_cst) != nullptr | |
| 3338 | 207 | }; | |
| 3339 | 207 | s_xinput_ex_original.store(nullptr, std::memory_order_seq_cst); | |
| 3340 | 207 | s_xinput_original.store(nullptr, std::memory_order_seq_cst); | |
| 3341 | 207 | return published_chains; | |
| 3342 | } | ||
| 3343 | |||
| 3344 | /** | ||
| 3345 | * @brief Waits a bounded time for detours that already copied a trampoline. | ||
| 3346 | * @return True if a detour body or route entry is in flight after the bounded wait. | ||
| 3347 | * @note Requires s_intercept_mutex and a constructed process-lifetime cell. | ||
| 3348 | */ | ||
| 3349 | 207 | [[nodiscard]] bool quiesce_xinput_routes_until_deadline() noexcept | |
| 3350 | { | ||
| 3351 | // Use a wall-clock bound, not a yield count. A hot game thread can enter after pointer retirement, and | ||
| 3352 | // teardown must still progress. | ||
| 3353 | 207 | constexpr uint64_t xinput_quiesce_timeout_ms = 10; | |
| 3354 | 207 | const uint64_t quiesce_deadline_ms = GetTickCount64() + xinput_quiesce_timeout_ms; | |
| 3355 | 116841 | while ((s_xinput_inflight.load(std::memory_order_seq_cst) != 0 || xinput_route_probe_inflight() != 0 || | |
| 3356 | 8035 | active_xinput_raw_scopes() != 0 || s_xinput_permanent_hooks->primary.route_entries() != 0 || | |
| 3357 | 264769 | s_xinput_permanent_hooks->ex.route_entries() != 0) && | |
| 3358 | 132080 | GetTickCount64() < quiesce_deadline_ms) | |
| 3359 | { | ||
| 3360 | 132076 | std::this_thread::yield(); | |
| 3361 | } | ||
| 3362 | |||
| 3363 | 410 | return s_xinput_inflight.load(std::memory_order_seq_cst) != 0 || xinput_route_probe_inflight() != 0 || | |
| 3364 | 820 | active_xinput_raw_scopes() != 0 || s_xinput_permanent_hooks->primary.route_entries() != 0 || | |
| 3365 | 410 | s_xinput_permanent_hooks->ex.route_entries() != 0; | |
| 3366 | } | ||
| 3367 | |||
| 3368 | /** | ||
| 3369 | * @brief Re-arms the restored Ex member after a refused primary restore. | ||
| 3370 | * @return The Ex teardown witness, or PatchWitness::Original if the re-arm did not restore the forward path. | ||
| 3371 | * @note Requires s_intercept_mutex and a constructed process-lifetime cell. | ||
| 3372 | */ | ||
| 3373 | 4 | [[nodiscard]] PatchWitness compensate_xinput_ex_restore() noexcept | |
| 3374 | { | ||
| 3375 | 4 | return rearm_xinput_hook( | |
| 3376 | 4 | s_xinput_permanent_hooks->ex, | |
| 3377 | s_xinput_ex_original, | ||
| 3378 | s_xinput_ex_enable_warned, | ||
| 3379 | 4 | "InputIntercept: the XInputGetStateEx re-arm that compensates a refused primary " | |
| 3380 | "restore did not complete cleanly; Ex state was reconciled from the target bytes." | ||
| 3381 | ) | ||
| 3382 | 4 | ? xinput_teardown_witness(s_xinput_permanent_hooks->ex) | |
| 3383 | 1 | : PatchWitness::Original; | |
| 3384 | } | ||
| 3385 | |||
| 3386 | /// Returns the first pair witness that refuses a restore, or PatchWitness::Original when both permit one. | ||
| 3387 | 203 | [[nodiscard]] constexpr PatchWitness restore_refusal(PatchWitness primary, PatchWitness ex) noexcept | |
| 3388 | { | ||
| 3389 | 203 | if (!witness_permits_write(primary)) | |
| 3390 | { | ||
| 3391 | 70 | return primary; | |
| 3392 | } | ||
| 3393 | 133 | return witness_permits_write(ex) ? PatchWitness::Original : ex; | |
| 3394 | } | ||
| 3395 | |||
| 3396 | /// Records a route-bypass retirement for the report after s_intercept_mutex releases. | ||
| 3397 | struct XInputBypassLog | ||
| 3398 | { | ||
| 3399 | /// The witness that refused the restore, or PatchWitness::Original when only claimed positions retired. | ||
| 3400 | PatchWitness refusal{PatchWitness::Original}; | ||
| 3401 | const char *primary_retained{nullptr}; | ||
| 3402 | const char *ex_retained{nullptr}; | ||
| 3403 | bool pending{false}; | ||
| 3404 | }; | ||
| 3405 | |||
| 3406 | /** | ||
| 3407 | * @brief Retires the drained pair by route bypass and gives each position to a later generation. | ||
| 3408 | * @details No step writes a target byte. Each closed route forwards to its trampoline and reaches no code of | ||
| 3409 | * this image, so the self-reference goes. A fresh position books HookResidue and keeps the target | ||
| 3410 | * references for good. A claimed position relies on the references of the generation that first | ||
| 3411 | * vacated it. The primary vacates first, so after a refused Ex vacate no primary hook remains that a | ||
| 3412 | * later maintenance can publish without its partner. | ||
| 3413 | * @return False when the coordinator is unavailable, a fresh position exceeds the residue budget, or a vacate | ||
| 3414 | * refused. A member that did not vacate keeps its state, and the caller retains it. | ||
| 3415 | * @note Requires s_intercept_mutex, a constructed process-lifetime cell, and routes that drained while CLOSING. | ||
| 3416 | */ | ||
| 3417 | 84 | [[nodiscard]] bool retire_xinput_pair_by_bypass(PatchWitness refusal, XInputBypassLog &deferred_log) noexcept | |
| 3418 | { | ||
| 3419 | 84 | PermanentXInputHooks *const permanent = permanent_cell(); | |
| 3420 | const std::array<safetyhook::InlineHook *, 2> members{ | ||
| 3421 | 84 | &permanent->primary, | |
| 3422 | 84 | &permanent->ex, | |
| 3423 | 84 | }; | |
| 3424 | // A fresh member without a committed patch has no target entry, so it resets as on the clean path. | ||
| 3425 | 336 | const auto vacates = [](const safetyhook::InlineHook &member) noexcept -> bool | |
| 3426 | 336 | { return member && (member.claimed() || member.enabled()); }; | |
| 3427 | 84 | bool fresh_vacated = false; | |
| 3428 | { | ||
| 3429 | // Each vacate acquires the held coordinator again without a wait. | ||
| 3430 | 84 | const safetyhook::ProcessCoordinator coordinator; | |
| 3431 | 84 | if (!coordinator) | |
| 3432 | { | ||
| 3433 | ✗ | return false; | |
| 3434 | } | ||
| 3435 | 252 | for (const safetyhook::InlineHook *const member : members) | |
| 3436 | { | ||
| 3437 | 222 | if (vacates(*member) && !member->claimed() && | |
| 3438 | 54 | !residue_fits_budget(coordinator, member->trampoline().data())) | |
| 3439 | { | ||
| 3440 | ✗ | return false; | |
| 3441 | } | ||
| 3442 | } | ||
| 3443 | 252 | for (safetyhook::InlineHook *const member : members) | |
| 3444 | { | ||
| 3445 | 168 | if (!vacates(*member)) | |
| 3446 | { | ||
| 3447 | ✗ | continue; | |
| 3448 | } | ||
| 3449 | 168 | const bool fresh = !member->claimed(); | |
| 3450 | // A claimed member reports bypassed only after bypass() clears its enabled state. | ||
| 3451 | 168 | if (fresh || !member->bypass()) | |
| 3452 | { | ||
| 3453 | 54 | member->finish_route_rundown(); | |
| 3454 | } | ||
| 3455 | 168 | if (!member->vacate()) | |
| 3456 | { | ||
| 3457 | ✗ | return false; | |
| 3458 | } | ||
| 3459 | 168 | if (fresh) | |
| 3460 | { | ||
| 3461 | 54 | diagnostics::record_intentional_leak(diagnostics::LeakSubsystem::HookResidue); | |
| 3462 | 54 | fresh_vacated = true; | |
| 3463 | } | ||
| 3464 | } | ||
| 3465 | 84 | } | |
| 3466 | |||
| 3467 | 84 | deferred_log.ex_retained = | |
| 3468 | 84 | permanent->ex ? reset_inactive_xinput_hook(permanent->ex, s_xinput_ex_original) : nullptr; | |
| 3469 | 84 | deferred_log.primary_retained = | |
| 3470 | 84 | permanent->primary ? reset_inactive_xinput_hook(permanent->primary, s_xinput_original) : nullptr; | |
| 3471 | 84 | if (deferred_log.primary_retained != nullptr || deferred_log.ex_retained != nullptr) | |
| 3472 | { | ||
| 3473 | ✗ | retire_xinput_module_refs(XInputChainReach::ProviderAndImage); | |
| 3474 | } | ||
| 3475 | else | ||
| 3476 | { | ||
| 3477 | 84 | retire_xinput_module_refs(fresh_vacated ? XInputChainReach::Provider : XInputChainReach::Nothing); | |
| 3478 | } | ||
| 3479 | 84 | clear_xinput_install_state(); | |
| 3480 | 84 | deferred_log.refusal = refusal; | |
| 3481 | 84 | deferred_log.pending = true; | |
| 3482 | 84 | return true; | |
| 3483 | } | ||
| 3484 | |||
| 3485 | /** @brief Reports a route-bypass retirement after its caller releases s_intercept_mutex. */ | ||
| 3486 | 84 | void emit_xinput_bypass_log(const XInputBypassLog &deferred_log) noexcept | |
| 3487 | { | ||
| 3488 | 84 | if (!deferred_log.pending) | |
| 3489 | { | ||
| 3490 | ✗ | return; | |
| 3491 | } | ||
| 3492 | 84 | if (deferred_log.refusal == PatchWitness::Original) | |
| 3493 | { | ||
| 3494 | 24 | (void)log().log_noexcept( | |
| 3495 | LogLevel::Info, | ||
| 3496 | 12 | "InputIntercept: the XInput pair retired its claimed positions." | |
| 3497 | ); | ||
| 3498 | } | ||
| 3499 | else | ||
| 3500 | { | ||
| 3501 | 144 | (void)log().try_log( | |
| 3502 | LogLevel::Info, | ||
| 3503 | "InputIntercept: the XInput pair retired by route bypass under a newer layer: {}. Its positions " | ||
| 3504 | "stay for a later generation.", | ||
| 3505 | 144 | witness_description(deferred_log.refusal) | |
| 3506 | ); | ||
| 3507 | } | ||
| 3508 | 84 | emit_xinput_route_retention_logs(deferred_log.primary_retained, deferred_log.ex_retained); | |
| 3509 | } | ||
| 3510 | } // anonymous namespace | ||
| 3511 | |||
| 3512 | 762 | void uninstall(std::uint64_t owner) noexcept | |
| 3513 | { | ||
| 3514 | 762 | InterceptLockGuard lock{s_intercept_mutex}; | |
| 3515 | 762 | XInputRetentionLog deferred_log; | |
| 3516 | 1524 | if (owner == 0 || s_intercept_owner.load(std::memory_order_relaxed) != owner) | |
| 3517 | { | ||
| 3518 | 472 | return; | |
| 3519 | } | ||
| 3520 | |||
| 3521 | // revoke_owner_and_clear_data() atomically revokes the layer and clears its data before teardown. The active | ||
| 3522 | // InterceptLockGuard serializes teardown. Data-plane writers use s_data_plane_mutex, so a revoked owner cannot | ||
| 3523 | // publish live state. | ||
| 3524 | 290 | revoke_owner_and_clear_data(); | |
| 3525 | |||
| 3526 | 290 | uninstall_message_hook(); | |
| 3527 | |||
| 3528 | 290 | if (s_xinput_permanent_detour.load(std::memory_order_acquire)) | |
| 3529 | { | ||
| 3530 | // A prior timeout or uncertain restore made the canonical hooks permanent. | ||
| 3531 | // This call only disarms the logical layer. A reachable retained entry continues to forward. | ||
| 3532 | 9 | clear_xinput_install_state(); | |
| 3533 | 9 | return; | |
| 3534 | } | ||
| 3535 | |||
| 3536 | 281 | if (s_xinput_permanent_hooks == nullptr) | |
| 3537 | { | ||
| 3538 | 74 | s_xinput_installed.store(false, std::memory_order_release); | |
| 3539 | 74 | s_xinput_pair_degraded.store(false, std::memory_order_release); | |
| 3540 | 74 | xinput_recovery_reset(); | |
| 3541 | 74 | return; | |
| 3542 | } | ||
| 3543 | |||
| 3544 | // The exit structure resolves both routes from their byte witnesses. Retention exits use retain_xinput_hooks. | ||
| 3545 | // The bypass exit uses retire_xinput_pair_by_bypass. The clean exit uses reset_inactive_xinput_hook. | ||
| 3546 | 207 | const XInputPublishedChains published_chains = retire_published_xinput_chains(); | |
| 3547 | 207 | const bool route_still_inflight = quiesce_xinput_routes_until_deadline(); | |
| 3548 | 207 | if (route_still_inflight) | |
| 3549 | { | ||
| 3550 | 4 | retain_xinput_hooks( | |
| 3551 | 4 | xinput_teardown_witness(s_xinput_permanent_hooks->primary), | |
| 3552 | 4 | xinput_teardown_witness(s_xinput_permanent_hooks->ex), | |
| 3553 | XInputRetentionReason::InflightTimeout, | ||
| 3554 | deferred_log, | ||
| 3555 | published_chains | ||
| 3556 | ); | ||
| 3557 | 4 | lock.unlock(); | |
| 3558 | 4 | emit_xinput_retention_log(deferred_log); | |
| 3559 | 4 | return; | |
| 3560 | } | ||
| 3561 | |||
| 3562 | // Classify both targets before either backend restore. A newer layer on either prologue or a claimed position | ||
| 3563 | // retires the whole pair by route bypass, so no partial teardown overwrites that layer. Retention remains | ||
| 3564 | // only for the refusals that retire_xinput_pair_by_bypass names. | ||
| 3565 | 203 | const PatchWitness primary_before = xinput_teardown_witness(s_xinput_permanent_hooks->primary); | |
| 3566 | 203 | const PatchWitness ex_before = xinput_teardown_witness(s_xinput_permanent_hooks->ex); | |
| 3567 | 203 | const PatchWitness refusal = restore_refusal(primary_before, ex_before); | |
| 3568 | 322 | if (refusal != PatchWitness::Original || s_xinput_permanent_hooks->primary.claimed() || | |
| 3569 | 119 | s_xinput_permanent_hooks->ex.claimed()) | |
| 3570 | { | ||
| 3571 | 84 | XInputBypassLog bypass_log; | |
| 3572 | 84 | if (!retire_xinput_pair_by_bypass(refusal, bypass_log)) | |
| 3573 | { | ||
| 3574 | ✗ | retain_xinput_hooks( | |
| 3575 | ✗ | xinput_teardown_witness(s_xinput_permanent_hooks->primary), | |
| 3576 | ✗ | xinput_teardown_witness(s_xinput_permanent_hooks->ex), | |
| 3577 | XInputRetentionReason::UnrestoredPatch, | ||
| 3578 | deferred_log | ||
| 3579 | ); | ||
| 3580 | } | ||
| 3581 | 84 | lock.unlock(); | |
| 3582 | 84 | emit_xinput_bypass_log(bypass_log); | |
| 3583 | 84 | emit_xinput_retention_log(deferred_log); | |
| 3584 | 84 | return; | |
| 3585 | } | ||
| 3586 | |||
| 3587 | // Restore Ex first, then primary. A caught exception can occur before or after either mutation, so the byte | ||
| 3588 | // witness after each call is authoritative. Only Original permits the hook object and keepalives to be freed. | ||
| 3589 | 119 | const PatchWitness ex_after = restore_xinput_hook(s_xinput_permanent_hooks->ex); | |
| 3590 | 119 | if (ex_after != PatchWitness::Original) | |
| 3591 | { | ||
| 3592 | 1 | retain_xinput_hooks(primary_before, ex_after, XInputRetentionReason::UnrestoredPatch, deferred_log); | |
| 3593 | 1 | lock.unlock(); | |
| 3594 | 1 | emit_xinput_retention_log(deferred_log); | |
| 3595 | 1 | return; | |
| 3596 | } | ||
| 3597 | 118 | const PatchWitness primary_after = restore_xinput_hook(s_xinput_permanent_hooks->primary); | |
| 3598 | 118 | if (primary_after != PatchWitness::Original) | |
| 3599 | { | ||
| 3600 | // The pair is one transaction and this half did not commit. Put the Ex member back first. Immediate | ||
| 3601 | // retention publishes a primary-only chain and permanently drops the covered ordinal-100 entry point. | ||
| 3602 | // Compensation reuses the current hook, and its own witness gate declines rather than fight a newer | ||
| 3603 | // writer. | ||
| 3604 | 4 | const PatchWitness ex_compensated = compensate_xinput_ex_restore(); | |
| 3605 | 4 | retain_xinput_hooks(primary_after, ex_compensated, XInputRetentionReason::UnrestoredPatch, deferred_log); | |
| 3606 | 4 | lock.unlock(); | |
| 3607 | 4 | emit_xinput_retention_log(deferred_log); | |
| 3608 | 4 | return; | |
| 3609 | } | ||
| 3610 | |||
| 3611 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 3612 | 114 | if (const XInputCleanReleaseSeam seam = s_xinput_clean_release_seam.load(std::memory_order_acquire); | |
| 3613 | seam != nullptr) | ||
| 3614 | { | ||
| 3615 | 4 | seam(); | |
| 3616 | } | ||
| 3617 | #endif | ||
| 3618 | |||
| 3619 | 114 | const char *const ex_retained = reset_inactive_xinput_hook(s_xinput_permanent_hooks->ex, s_xinput_ex_original); | |
| 3620 | const char *const primary_retained = | ||
| 3621 | 114 | reset_inactive_xinput_hook(s_xinput_permanent_hooks->primary, s_xinput_original); | |
| 3622 | |||
| 3623 | // A retained chain can still reach a code provider after its detour body drains. | ||
| 3624 | 114 | retire_xinput_module_refs(reset_chain_reach(primary_retained || ex_retained)); | |
| 3625 | |||
| 3626 | 114 | clear_xinput_install_state(); | |
| 3627 | 114 | lock.unlock(); | |
| 3628 | 114 | emit_xinput_route_retention_logs(primary_retained, ex_retained); | |
| 3629 | 762 | } | |
| 3630 | |||
| 3631 | } // namespace DetourModKit::detail | ||
| 3632 |