src/internal/input_intercept.hpp
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | #ifndef DETOURMODKIT_INTERNAL_INPUT_INTERCEPT_HPP | ||
| 2 | #define DETOURMODKIT_INTERNAL_INPUT_INTERCEPT_HPP | ||
| 3 | |||
| 4 | /** | ||
| 5 | * @file input_intercept.hpp | ||
| 6 | * @brief Internal active-input layer driven by InputPoller. | ||
| 7 | * @details The layer masks digital gamepad buttons and captures wheel events for InputPoller. | ||
| 8 | * It owns both XInput hooks, code-provider references, and shared atomic data. | ||
| 9 | * Retained hooks keep their executable chains and native TLS owners until process exit. | ||
| 10 | * | ||
| 11 | * [B-95] serializes owner changes and data writes. Lock order: s_intercept_mutex, then the data-plane lock. | ||
| 12 | * Detours take neither lock. They use atomics, numeric native TLS, and the rule seqlock. | ||
| 13 | * This Windows-only header remains private. | ||
| 14 | */ | ||
| 15 | |||
| 16 | #include <windows.h> | ||
| 17 | #include <xinput.h> | ||
| 18 | |||
| 19 | #include <array> | ||
| 20 | #include <cstddef> | ||
| 21 | #include <cstdint> | ||
| 22 | #include <string_view> | ||
| 23 | |||
| 24 | namespace DetourModKit::detail | ||
| 25 | { | ||
| 26 | /// Function-pointer type for XInputGetState and the ordinal-100 XInputGetStateEx. | ||
| 27 | using XInputGetStateFn = DWORD(WINAPI *)(DWORD, XINPUT_STATE *); | ||
| 28 | |||
| 29 | /** | ||
| 30 | * @struct WheelPulseState | ||
| 31 | * @brief Poll-thread-private state that turns queued wheel notches into single-cycle pulses. | ||
| 32 | * @details The wheel has no released state, so the poll loop synthesizes one: | ||
| 33 | * a notch reads as "pressed" for exactly one cycle, then is forced low for one cycle so the edge detector | ||
| 34 | * re-arms. Without the forced gap a continuous scroll would read as one long press and fire only once. | ||
| 35 | * Indices are 0=Up, 1=Down, 2=Left, 3=Right. | ||
| 36 | */ | ||
| 37 | struct WheelPulseState | ||
| 38 | { | ||
| 39 | /// Unconsumed notches per direction. | ||
| 40 | std::array<int, 4> pending{}; | ||
| 41 | /// Whether the previous cycle emitted a pulse. | ||
| 42 | std::array<bool, 4> pulsing{}; | ||
| 43 | }; | ||
| 44 | |||
| 45 | /** | ||
| 46 | * @brief Maximum unconsumed notches retained per direction. | ||
| 47 | * @details The pulse stepper drains at most one notch per direction every two poll cycles (one cycle pulses, the | ||
| 48 | * next forces the re-arm gap), so a scroll faster than that drain accumulates a backlog. Capping it bounds | ||
| 49 | * how long phantom notches can replay after the user stops scrolling; a real burst rarely exceeds this, | ||
| 50 | * and dropping the tail of an extreme burst is preferable to an unbounded replay queue. | ||
| 51 | */ | ||
| 52 | inline constexpr int MAX_WHEEL_PENDING = 16; | ||
| 53 | |||
| 54 | /** | ||
| 55 | * @brief Advances the wheel pulse state machine by one poll cycle. | ||
| 56 | * @param state Per-direction pulse state, carried across cycles. | ||
| 57 | * @return Bitmask of directions pressed this cycle (bit 0 = Up .. bit 3 = Right). | ||
| 58 | */ | ||
| 59 | [[nodiscard]] uint8_t step_wheel_pulse(WheelPulseState &state) noexcept; | ||
| 60 | |||
| 61 | /** | ||
| 62 | * @brief Adds freshly drained wheel notches to the pending backlog, capped. | ||
| 63 | * @details Each retained notch still maps to one Press edge via step_wheel_pulse; | ||
| 64 | * this only bounds the carried-over backlog per direction to @ref MAX_WHEEL_PENDING so a sustained fast | ||
| 65 | * scroll cannot queue notches faster than they drain. Negative inputs are ignored so a corrupt count | ||
| 66 | * cannot drive pending negative and underflow the drain. | ||
| 67 | * @param state Pulse state whose pending counts are updated in place. | ||
| 68 | * @param taken Notch counts just drained from the detour, indexed 0=Up..3=Right. | ||
| 69 | */ | ||
| 70 | void add_wheel_notches(WheelPulseState &state, const std::array<int, 4> &taken) noexcept; | ||
| 71 | |||
| 72 | /** | ||
| 73 | * @struct GamepadSuppressState | ||
| 74 | * @brief Poll-thread-private consume-until-release latch for suppressed gamepad buttons. | ||
| 75 | */ | ||
| 76 | struct GamepadSuppressState | ||
| 77 | { | ||
| 78 | /// Currently suppressed XInput button bits. | ||
| 79 | uint16_t armed{0}; | ||
| 80 | /// Per-bit release deadline; a held bit uses the sentinel. | ||
| 81 | std::array<uint64_t, 16> deadline_ms{}; | ||
| 82 | }; | ||
| 83 | |||
| 84 | /** | ||
| 85 | * @brief Advances the gamepad suppression latch by one poll cycle. | ||
| 86 | * @details A bit stays suppressed from the moment an active consume chord claims it (@p owned_now) until the | ||
| 87 | * physical button is released (@p true_buttons no longer has it) plus @p grace_ms. This closes the | ||
| 88 | * modifier-released-before-trigger window: releasing the modifier a frame before the trigger cannot leak a | ||
| 89 | * bare trigger to the game, because suppression is latched to the trigger button's own lifetime, not the | ||
| 90 | * chord's. | ||
| 91 | * @param state Latch state carried across cycles. | ||
| 92 | * @param owned_now Digital button bits the active consume chords claim this cycle (each bit's physical button is | ||
| 93 | * already known pressed). | ||
| 94 | * @param true_buttons The unmasked XINPUT_GAMEPAD.wButtons read this cycle. | ||
| 95 | * @param now_ms Monotonic millisecond timestamp for this cycle. | ||
| 96 | * @param grace_ms Release grace window in milliseconds. | ||
| 97 | * @return Bitmask of button bits to clear from the game's state this cycle. | ||
| 98 | */ | ||
| 99 | [[nodiscard]] uint16_t step_gamepad_suppress( | ||
| 100 | GamepadSuppressState &state, | ||
| 101 | uint16_t owned_now, | ||
| 102 | uint16_t true_buttons, | ||
| 103 | uint64_t now_ms, | ||
| 104 | uint64_t grace_ms | ||
| 105 | ) noexcept; | ||
| 106 | |||
| 107 | /** | ||
| 108 | * @struct GamepadConsumeRule | ||
| 109 | * @brief A consume chord reduced to the XInput button bits the detour can evaluate without the poll thread. | ||
| 110 | * @details The reactive (poll-published) mask trails the physical state by up to one poll cycle. A rule lets the | ||
| 111 | * detour mask the trigger against the exact snapshot that the game is about to read. This closes the | ||
| 112 | * window. | ||
| 113 | * Built only from chords whose modifiers and masked triggers are all digital gamepad buttons, so the | ||
| 114 | * decision is fully reproducible from XINPUT_GAMEPAD.wButtons alone. | ||
| 115 | */ | ||
| 116 | struct GamepadConsumeRule | ||
| 117 | { | ||
| 118 | /// Digital button bits that must all be held. | ||
| 119 | uint16_t modifier_mask{0}; | ||
| 120 | /// Known-modifier bits outside this chord; any held rejects it (strict match). | ||
| 121 | uint16_t forbidden_mask{0}; | ||
| 122 | /// Digital button bits to clear when the chord matches. | ||
| 123 | uint16_t trigger_mask{0}; | ||
| 124 | }; | ||
| 125 | |||
| 126 | /** | ||
| 127 | * @brief Maximum number of consume rules the detour evaluates. | ||
| 128 | * @details The bound is the detour's storage, not a policy: a longer list publishes its first this-many rules and | ||
| 129 | * drops the remainder. Evaluation ORs each matching rule's trigger | ||
| 130 | * mask, so a dropped rule costs exactly its own chord the leading-edge protection and costs the retained | ||
| 131 | * rules nothing. | ||
| 132 | */ | ||
| 133 | inline constexpr std::size_t MAX_GAMEPAD_CONSUME_RULES = 32; | ||
| 134 | |||
| 135 | /** | ||
| 136 | * @struct ConsumePublish | ||
| 137 | * @brief Outcome of an attempted consume-rule publication. | ||
| 138 | */ | ||
| 139 | struct ConsumePublish | ||
| 140 | { | ||
| 141 | /// False when the caller did not hold the layer, in which case nothing was written. | ||
| 142 | bool authorized{false}; | ||
| 143 | /// Rules actually written, which is @c min(count, MAX_GAMEPAD_CONSUME_RULES) when authorized and 0 otherwise. | ||
| 144 | std::size_t published{0}; | ||
| 145 | }; | ||
| 146 | |||
| 147 | /** | ||
| 148 | * @brief Evaluates consume rules against a raw button snapshot. | ||
| 149 | * @details Pure helper shared by the XInput detour and its tests. A rule contributes its @ref | ||
| 150 | * GamepadConsumeRule::trigger_mask when every @ref GamepadConsumeRule::modifier_mask bit is present in @p | ||
| 151 | * true_buttons and no @ref GamepadConsumeRule::forbidden_mask bit is. Masking a trigger bit that is not | ||
| 152 | * currently down is a no-op against the game's state, so a rule may match before its trigger is pressed | ||
| 153 | * without observable effect. | ||
| 154 | * @param true_buttons The unmasked XINPUT_GAMEPAD.wButtons the game will read. | ||
| 155 | * @param rules Pointer to @p count contiguous rules (may be nullptr if 0). | ||
| 156 | * @param count Number of rules. | ||
| 157 | * @return Button bits to clear from the game's state. | ||
| 158 | */ | ||
| 159 | [[nodiscard]] uint16_t | ||
| 160 | evaluate_consume_rules(uint16_t true_buttons, const GamepadConsumeRule *rules, std::size_t count) noexcept; | ||
| 161 | |||
| 162 | /** | ||
| 163 | * @brief Publishes the consume rule list read by the XInput detour, if @p owner still holds the layer. | ||
| 164 | * @details Copies up to @ref MAX_GAMEPAD_CONSUME_RULES rules behind a seqlock so a detour on a game thread reads a | ||
| 165 | * consistent snapshot without locking. A @p count above the cap publishes the first @ref | ||
| 166 | * MAX_GAMEPAD_CONSUME_RULES; the caller derives the shortfall from the result and owns the diagnosis. | ||
| 167 | * Rule masking shares the reactive mask's time-to-live (rules exist only while consume gamepad bindings | ||
| 168 | * do, which is exactly when publish_gamepad_suppress refreshes the deadline), so a stalled poll thread | ||
| 169 | * stops rule masking too. | ||
| 170 | * @param rules Pointer to @p count contiguous rules (may be nullptr if 0). | ||
| 171 | * @param count Number of rules offered. | ||
| 172 | * @param owner Nonzero owner id that must equal the live layer owner; any other value writes nothing. | ||
| 173 | */ | ||
| 174 | [[nodiscard]] ConsumePublish | ||
| 175 | publish_gamepad_consume_rules(const GamepadConsumeRule *rules, std::size_t count, std::uint64_t owner) noexcept; | ||
| 176 | |||
| 177 | /** | ||
| 178 | * @brief Reads the published consume rule list and evaluates it against a raw button snapshot. | ||
| 179 | * @details The XInput detour's rule-read side, exported for testing. Reads the seqlock-guarded rule list in a | ||
| 180 | * single attempt (a torn or mid-update snapshot yields 0) and returns evaluate_consume_rules over it. This | ||
| 181 | * is independent of the focus gate (see set_gamepad_rule_suppress_enabled), which the detour applies | ||
| 182 | * separately. | ||
| 183 | * @param true_buttons The unmasked XINPUT_GAMEPAD.wButtons the game will read. | ||
| 184 | * @return Button bits the currently published rules would clear. | ||
| 185 | */ | ||
| 186 | [[nodiscard]] uint16_t evaluate_published_consume_rules(uint16_t true_buttons) noexcept; | ||
| 187 | |||
| 188 | /** | ||
| 189 | * @brief Enables or disables detour-side consume-rule masking, if @p owner still holds the layer. | ||
| 190 | * @details Gates whether the XInput detour evaluates the published rule list. The poll thread drives this every | ||
| 191 | * cycle so rule masking stops the instant the host window loses focus or the controller disconnects, | ||
| 192 | * matching the reactive mask (which the poll loop clears to 0 on focus loss) and the mouse-wheel consume | ||
| 193 | * flag. Without it the detour would keep masking the foreground game's gamepad input while the mod is in | ||
| 194 | * the background, because the published rule list and its time-to-live both stay alive across focus | ||
| 195 | * changes. | ||
| 196 | * @param enabled True to evaluate rules, false to skip them. | ||
| 197 | * @param owner Nonzero owner id that must equal the live layer owner; any other value changes nothing. | ||
| 198 | * @return true when the gate was written. | ||
| 199 | */ | ||
| 200 | [[nodiscard]] bool set_gamepad_rule_suppress_enabled(bool enabled, std::uint64_t owner) noexcept; | ||
| 201 | |||
| 202 | /** | ||
| 203 | * @brief Owner id for a standalone caller (tests, direct install/uninstall) that drives the layer without a poller. | ||
| 204 | * @details Zero is reserved for the unowned state. Pollers use ids from next_intercept_owner(). | ||
| 205 | */ | ||
| 206 | inline constexpr std::uint64_t STANDALONE_INTERCEPT_OWNER = 1; | ||
| 207 | |||
| 208 | /** | ||
| 209 | * @brief Draws an interception-owner id distinct from the two reserved values. | ||
| 210 | * @details Each poller retains one id across installation and teardown so a superseded poller cannot remove a newer | ||
| 211 | * poller's hooks. | ||
| 212 | */ | ||
| 213 | [[nodiscard]] std::uint64_t next_intercept_owner() noexcept; | ||
| 214 | |||
| 215 | /// Reports whether the interception layer is currently held by the nonzero @p owner. | ||
| 216 | [[nodiscard]] bool intercept_owned_by(std::uint64_t owner) noexcept; | ||
| 217 | |||
| 218 | // XInput interception (gamepad passthrough suppression) | ||
| 219 | |||
| 220 | /** | ||
| 221 | * @brief Installs the XInputGetState hook pair for the given controller index under @p owner. | ||
| 222 | * @details Both required exports form one transaction. An absent or aliased ordinal 100 needs no second hook. | ||
| 223 | * Both hooks start disabled. Target modules and retained storage receive references before publication. | ||
| 224 | * Creation failure leaves ownership unchanged. Ambiguous target bytes retain any reachable chain. | ||
| 225 | * | ||
| 226 | * Exact patch witnesses authorize owned entries. Foreign entries require scoped calls through both | ||
| 227 | * exports, matching detour receipts, successful controller samples, and an unchanged final snapshot. | ||
| 228 | * A receipt proves only its invocation. A conditional foreign handler can bypass other callers. | ||
| 229 | * Every maintenance call checks both members. A failed check disarms both detours. | ||
| 230 | * | ||
| 231 | * Recovery uses each member's existing hook. Failed re-arms receive a bounded retry delay. | ||
| 232 | * Owner, target, or member changes reset that delay. Foreign calls run outside every DMK lock. | ||
| 233 | * @param user_index Controller whose state can receive a mask. | ||
| 234 | * @param owner Nonzero interception-layer owner id. | ||
| 235 | * @return True when both required routes have exact or observed evidence for this owner. Otherwise false. | ||
| 236 | * @note Best-effort. An upstream handler has no execution deadline. Revoked probe leases retain executable | ||
| 237 | * chains when the teardown deadline expires. Exact witnesses still govern every byte mutation. | ||
| 238 | */ | ||
| 239 | [[nodiscard]] bool install_xinput(int user_index, std::uint64_t owner = STANDALONE_INTERCEPT_OWNER) noexcept; | ||
| 240 | |||
| 241 | /** | ||
| 242 | * @brief Returns whether paired route evidence currently authorizes XInput suppression. | ||
| 243 | * @details False after a failed paired check or logical teardown. Maintenance can restore authorization. | ||
| 244 | */ | ||
| 245 | [[nodiscard]] bool xinput_installed() noexcept; | ||
| 246 | |||
| 247 | /** | ||
| 248 | * @brief Returns the published original XInputGetState chain, or nullptr after logical teardown. | ||
| 249 | * @details A degraded live pair retains its chain. The caller must hold the provider's lifetime. | ||
| 250 | * Pollers use sample_xinput_raw_state instead, so every compatible copy receives raw controller state. | ||
| 251 | */ | ||
| 252 | [[nodiscard]] XInputGetStateFn xinput_trampoline() noexcept; | ||
| 253 | |||
| 254 | /** | ||
| 255 | * @brief Samples the current export without suppression by compatible DMK copies. | ||
| 256 | * @details A refused scope skips the provider call and leaves state unchanged. | ||
| 257 | * Trampolines can traverse another compatible copy's suppression. | ||
| 258 | * @param user_index Controller to sample. | ||
| 259 | * @param state Receives the raw controller state. | ||
| 260 | * @param owner The poller's interception owner. | ||
| 261 | * @return False after a failed scope, disconnected controller, or TLS restore failure. | ||
| 262 | * @note The poller must retain a raw-scope owner across its complete worker lifetime. | ||
| 263 | */ | ||
| 264 | [[nodiscard]] bool sample_xinput_raw_state( | ||
| 265 | DWORD user_index, | ||
| 266 | XINPUT_STATE &state, | ||
| 267 | std::uint64_t owner = STANDALONE_INTERCEPT_OWNER | ||
| 268 | ) noexcept; | ||
| 269 | |||
| 270 | /** | ||
| 271 | * @brief Publishes the set of button bits the XInput detour should suppress, if @p owner still holds the layer. | ||
| 272 | * @details Refreshes a short time-to-live alongside the mask so that if the poll thread stops refreshing it | ||
| 273 | * (crash/hang) the detour stops masking and the game regains its input rather than latching forever. | ||
| 274 | * @param suppress_bits Button bits to clear; 0 disables masking. | ||
| 275 | * @param owner Nonzero owner id that must equal the live layer owner; any other value writes nothing. | ||
| 276 | * @return true when the mask was written. | ||
| 277 | */ | ||
| 278 | [[nodiscard]] bool publish_gamepad_suppress(uint16_t suppress_bits, std::uint64_t owner) noexcept; | ||
| 279 | |||
| 280 | // Mouse-wheel capture (thread-scoped WH_GETMESSAGE hook) | ||
| 281 | |||
| 282 | /** | ||
| 283 | * @brief Wheel direction bit positions in the per-direction consume mask. | ||
| 284 | * @details A single wheel message carries exactly one direction. The bit order matches WheelPulseState / the | ||
| 285 | * s_wheel_count slots (0=Up, 1=Down, 2=Left, 3=Right) so the poll loop, the pulse machine, and the hook | ||
| 286 | * callback agree on indexing. | ||
| 287 | */ | ||
| 288 | enum class WheelDirection : uint8_t | ||
| 289 | { | ||
| 290 | Up = 1u << 0, | ||
| 291 | Down = 1u << 1, | ||
| 292 | Left = 1u << 2, | ||
| 293 | Right = 1u << 3, | ||
| 294 | }; | ||
| 295 | |||
| 296 | /// Returns the mask bit for a wheel direction. | ||
| 297 | 1243 | [[nodiscard]] constexpr uint8_t wheel_direction_bit(WheelDirection direction) noexcept | |
| 298 | { | ||
| 299 | 1243 | return static_cast<uint8_t>(direction); | |
| 300 | } | ||
| 301 | |||
| 302 | /** | ||
| 303 | * @brief Hard ceiling on the raw per-direction wheel-notch counter the local wheel hook accumulates. | ||
| 304 | * @details `[B-25]` The poll loop drains the counter only while a wheel binding exists. The hook stays mounted | ||
| 305 | * until shutdown, so an undrained counter accretes idle notches toward signed overflow. | ||
| 306 | * Write-site saturation bounds it regardless of poll-thread liveness. The ceiling is far above | ||
| 307 | * any real burst, so only the pathological idle-accretion case saturates. | ||
| 308 | */ | ||
| 309 | inline constexpr int MAX_WHEEL_NOTCHES = 1024; | ||
| 310 | |||
| 311 | /** | ||
| 312 | * @enum WheelRouteState | ||
| 313 | * @brief Typed health of the local wheel route. Values mirror the DMK_WHEELHOST_ROUTE_* C ABI states. | ||
| 314 | */ | ||
| 315 | enum class WheelRouteState : std::uint8_t | ||
| 316 | { | ||
| 317 | /// No target thread is mounted. A mount attempt with a valid target can succeed. | ||
| 318 | TargetWait = 0, | ||
| 319 | /// The hook is mounted and the target thread is alive. | ||
| 320 | Ready = 1, | ||
| 321 | /// The route was lost or disabled (target exit, failed mount, or a failed drain). A remount can succeed. | ||
| 322 | Retryable = 2, | ||
| 323 | /// Old-hook removal failed on a live thread. New mounts are blocked until that thread exits. | ||
| 324 | CleanupBlocked = 3, | ||
| 325 | }; | ||
| 326 | |||
| 327 | /** | ||
| 328 | * @brief Mounts or migrates the local WH_GETMESSAGE wheel source onto @p target_thread_id under @p owner. | ||
| 329 | * @details One transaction serves the fresh mount, the idempotent same-thread call, and the migration to a new | ||
| 330 | * thread. A migration disables capture, advances the epoch, drains admitted callback phases (bounded), | ||
| 331 | * and removes the old hook before the new hook mounts, so hooks never overlap. A removal failure on a | ||
| 332 | * live thread publishes @ref WheelRouteState::CleanupBlocked and blocks the new mount. Takes a permanent | ||
| 333 | * MessageHookKeepalive on the first successful publication, because Windows permits a selected hook | ||
| 334 | * callback to run after UnhookWindowsHookEx returns. | ||
| 335 | * @param owner Nonzero interception-layer owner id shared with the XInput hook. | ||
| 336 | * @param target_thread_id The UI thread to mount on. It must belong to this process and be alive. | ||
| 337 | * @return true when the hook is mounted on @p target_thread_id for this owner; false when the target is invalid, | ||
| 338 | * the transaction is blocked, or another owner holds the layer. message_hook_route_state() carries the | ||
| 339 | * failure detail. | ||
| 340 | */ | ||
| 341 | [[nodiscard]] bool install_message_hook(std::uint64_t owner, std::uint32_t target_thread_id) noexcept; | ||
| 342 | |||
| 343 | /** | ||
| 344 | * @brief Returns whether the local wheel hook is mounted on a live target thread. | ||
| 345 | * @details Derived from the live thread handle, never from a sticky installed flag: a dead target retires the | ||
| 346 | * route and reads false. | ||
| 347 | */ | ||
| 348 | [[nodiscard]] bool message_hook_installed() noexcept; | ||
| 349 | |||
| 350 | /** | ||
| 351 | * @brief Returns the settled local route state after a target-liveness recheck. | ||
| 352 | * @details A cleanup-blocked route whose old thread exited becomes Retryable here. | ||
| 353 | */ | ||
| 354 | [[nodiscard]] WheelRouteState message_hook_route_state() noexcept; | ||
| 355 | |||
| 356 | /// Returns the mounted target thread id, or 0 while no live route exists. | ||
| 357 | [[nodiscard]] std::uint32_t message_hook_thread_id() noexcept; | ||
| 358 | |||
| 359 | /// Returns the local mount generation. A successful mount or migration increments it once. | ||
| 360 | [[nodiscard]] std::uint64_t message_hook_mount_generation() noexcept; | ||
| 361 | |||
| 362 | /** | ||
| 363 | * @brief Atomically takes and clears the accumulated wheel notch counts, if @p owner still holds the layer. | ||
| 364 | * @details Consuming notches is a destructive read of state the owner's poll loop is entitled to, so a non-owner | ||
| 365 | * reads all zeros and leaves the counters intact rather than swallowing the owner's backlog. | ||
| 366 | * @param owner Nonzero owner id that must equal the live layer owner. | ||
| 367 | * @return Notch counts since the last call, indexed 0=Up, 1=Down, 2=Left, 3=Right; all zero for a non-owner. | ||
| 368 | */ | ||
| 369 | [[nodiscard]] std::array<int, 4> take_wheel_counts(std::uint64_t owner) noexcept; | ||
| 370 | |||
| 371 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 372 | /// Claims the idle layer for STANDALONE_INTERCEPT_OWNER without installing a hook. | ||
| 373 | [[nodiscard]] bool acquire_standalone_lease_for_test() noexcept; | ||
| 374 | |||
| 375 | /// Releases the test-only standalone lease and clears the data it authorized. | ||
| 376 | void release_standalone_lease_for_test() noexcept; | ||
| 377 | |||
| 378 | /** | ||
| 379 | * @brief Test-only: stages a wheel-notch backlog as if the wheel hook had latched @p notches. | ||
| 380 | * @details The hook callback increments the counters only from a real WM_MOUSEWHEEL / WM_MOUSEHWHEEL retrieval, | ||
| 381 | * which the unit suite cannot deliver without a pumped queue. This seam lets a white-box test stand up | ||
| 382 | * the exact stale-backlog state that the poll loop's drain and recompute's no-wheel -> wheel discard | ||
| 383 | * exist to handle, so those paths are exercised deterministically. Each slot saturates at | ||
| 384 | * MAX_WHEEL_NOTCHES, matching the hook's write site. Compiled out of shipping archives. | ||
| 385 | */ | ||
| 386 | void seed_wheel_notches_for_test(const std::array<int, 4> ¬ches) noexcept; | ||
| 387 | #endif | ||
| 388 | |||
| 389 | /** | ||
| 390 | * @brief Publishes the set of wheel directions the wheel hook should swallow this cycle. | ||
| 391 | * @details Uses a per-direction mask so a chord such as "Ctrl+WheelUp" eats neither a bare WheelDown nor an | ||
| 392 | * unmodified WheelUp. The poll loop evaluates each consume wheel binding's modifiers every cycle and | ||
| 393 | * unions the owned direction bits (see WheelDirection). Like the gamepad reactive suppression mask, the | ||
| 394 | * hook only swallows a message whose own direction bit is set. A short time-to-live is refreshed | ||
| 395 | * alongside a non-zero mask so a stalled poll thread stops swallowing and the game regains its wheel. | ||
| 396 | * @param direction_mask OR of WheelDirection bits to swallow; 0 forwards every wheel message. | ||
| 397 | * @param require_focus When true, count admission and consume finalization also require process foreground. | ||
| 398 | * @param owner Nonzero owner id that must equal the live layer owner; any other value writes nothing. | ||
| 399 | * @return true when the mask was written. A closed wheel capture refuses the mask. | ||
| 400 | */ | ||
| 401 | [[nodiscard]] bool publish_wheel_consume(uint8_t direction_mask, bool require_focus, std::uint64_t owner) noexcept; | ||
| 402 | |||
| 403 | /** | ||
| 404 | * @brief Tears down both interceptors and stops all masking, if @p owner still holds the layer. | ||
| 405 | * @details Retires the owner before touching backend state. XInput removal drains game detours and requires | ||
| 406 | * Original byte witnesses for both hooks; timeout, foreign ownership, an unreadable window, or an | ||
| 407 | * unconfirmed toggle retains the pair and keepalives without allocation. The two raw members are one | ||
| 408 | * transaction: a primary restore that refuses after the ordinal-100 restore committed re-arms that member | ||
| 409 | * before retaining, so retention never drops an entry point the pair covered on entry. Wheel-hook | ||
| 410 | * removal drains admitted callback phases (bounded) after the epoch advance, then removes the OS hook. | ||
| 411 | * Idempotent. | ||
| 412 | * @param owner Nonzero interception-layer owner id. Any non-owner returns without changing the installation. | ||
| 413 | * @warning Never call under the loader lock, and never before the poll thread has been joined: that thread reads | ||
| 414 | * the current XInput export, and raw hook teardown registers VEH state and rewrites executable | ||
| 415 | * pages. | ||
| 416 | */ | ||
| 417 | void uninstall(std::uint64_t owner = STANDALONE_INTERCEPT_OWNER) noexcept; | ||
| 418 | |||
| 419 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 420 | /// Seam signature; see set_xinput_detour_body_seam. | ||
| 421 | using XInputDetourBodySeam = void (*)() noexcept; | ||
| 422 | |||
| 423 | /// Seam signature; see set_xinput_arm_seam. | ||
| 424 | using XInputArmSeam = void (*)() noexcept; | ||
| 425 | |||
| 426 | /// Seam signature; see set_xinput_clean_release_seam. | ||
| 427 | using XInputCleanReleaseSeam = void (*)() noexcept; | ||
| 428 | |||
| 429 | /// Seam signature for the allocation-free XInput retention attribution probe. | ||
| 430 | using XInputRetentionAttributionSeam = void (*)(std::string_view) noexcept; | ||
| 431 | |||
| 432 | /// Seam signature; see set_xinput_create_seam. | ||
| 433 | using XInputCreateSeam = void (*)() noexcept; | ||
| 434 | |||
| 435 | /** | ||
| 436 | * @brief Installs a probe that runs inside an XInput detour body while its in-flight guard is held. | ||
| 437 | * @details The only way to park a caller inside a detour deterministically, which is what makes uninstall()'s | ||
| 438 | * bounded drain time out on demand. Null clears it. Compiled out of shipping archives. | ||
| 439 | */ | ||
| 440 | void set_xinput_detour_body_seam(XInputDetourBodySeam seam) noexcept; | ||
| 441 | |||
| 442 | /// Holds or releases a raw-XInput caller after stable route admission but before the C++ detour body. | ||
| 443 | void set_xinput_route_entry_hold_for_test(bool hold) noexcept; | ||
| 444 | |||
| 445 | /// Reports whether a raw-XInput caller reached the stable pre-body route park. | ||
| 446 | [[nodiscard]] bool xinput_route_entry_reached_for_test() noexcept; | ||
| 447 | |||
| 448 | /** | ||
| 449 | * @brief Runs a probe after both raw targets are witnessed restored and immediately before hook-object release. | ||
| 450 | * @details Lets a lifecycle proof poison allocation only across the clean noexcept release boundary. Null clears | ||
| 451 | * the probe. | ||
| 452 | */ | ||
| 453 | void set_xinput_clean_release_seam(XInputCleanReleaseSeam seam) noexcept; | ||
| 454 | |||
| 455 | /** | ||
| 456 | * @brief Installs a probe that receives the fixed-buffer XInput retention attribution. | ||
| 457 | * @details The probe runs after the interception lock is released. | ||
| 458 | * A null value clears it. | ||
| 459 | */ | ||
| 460 | void set_xinput_retention_attribution_seam(XInputRetentionAttributionSeam seam) noexcept; | ||
| 461 | |||
| 462 | /** | ||
| 463 | * @brief Runs a probe after a raw hook's isolated allocator exists and before backend construction begins. | ||
| 464 | * @details Lets the lifecycle proof poison only allocations made inside InlineHook::create. Null clears it. | ||
| 465 | */ | ||
| 466 | void set_xinput_create_seam(XInputCreateSeam seam) noexcept; | ||
| 467 | |||
| 468 | /** | ||
| 469 | * @brief Arms one raw-XInput backend toggle exception at @p target. | ||
| 470 | * @param target Exact backend target, or nullptr to disarm the seam. | ||
| 471 | * @param after_mutation true to throw after the byte mutation; false to throw before it. | ||
| 472 | */ | ||
| 473 | void set_xinput_backend_toggle_exception_for_test(void *target, bool after_mutation) noexcept; | ||
| 474 | |||
| 475 | /// Returns how many raw-XInput backend exceptions the current test arm reached and contained. | ||
| 476 | [[nodiscard]] std::size_t xinput_backend_toggle_exception_catches_for_test() noexcept; | ||
| 477 | |||
| 478 | /** | ||
| 479 | * @brief Installs a probe between a raw-XInput backend toggle and the witness read that judges it. | ||
| 480 | * @details The only deterministic way to place a competing prologue writer in that exact window. Null clears it. | ||
| 481 | */ | ||
| 482 | void set_xinput_arm_seam(XInputArmSeam seam) noexcept; | ||
| 483 | |||
| 484 | /// Reports whether a claimed layer lacks paired route authorization. | ||
| 485 | [[nodiscard]] bool xinput_pair_degraded_for_test() noexcept; | ||
| 486 | |||
| 487 | /** | ||
| 488 | * @struct XInputPairCoverage | ||
| 489 | * @brief Separates evidence for each required pair member. | ||
| 490 | * @details The exact witness query treats an absent or aliased ordinal 100 as covered. | ||
| 491 | * The observation query reports receipts only for required members. | ||
| 492 | */ | ||
| 493 | struct XInputPairCoverage | ||
| 494 | { | ||
| 495 | bool primary{false}; | ||
| 496 | bool ex{false}; | ||
| 497 | }; | ||
| 498 | |||
| 499 | /// Re-witnesses both pair members' target bytes without changing published state. | ||
| 500 | [[nodiscard]] XInputPairCoverage xinput_pair_coverage_for_test() noexcept; | ||
| 501 | |||
| 502 | /** @brief Returns scoped route observations separately from exact-patch coverage. Test-only. */ | ||
| 503 | [[nodiscard]] XInputPairCoverage xinput_pair_observation_for_test() noexcept; | ||
| 504 | |||
| 505 | /** | ||
| 506 | * @brief Counts backend re-arm transactions the recovery gate has let through. | ||
| 507 | * @details The observable difference between "the poll loop retried the backend" and "the poll loop was refused by | ||
| 508 | * the deadline", which is what makes the capped-delay contract measurable without timing the test. | ||
| 509 | */ | ||
| 510 | [[nodiscard]] std::size_t xinput_recovery_attempts_for_test() noexcept; | ||
| 511 | |||
| 512 | /** | ||
| 513 | * @brief Expires the current recovery delay without resetting the accumulated backoff. | ||
| 514 | * @details Lets a proof drive many recovery attempts deterministically instead of sleeping out a growing delay. | ||
| 515 | * @return The accumulated delay that was expired, in milliseconds. | ||
| 516 | */ | ||
| 517 | [[nodiscard]] std::uint64_t expire_xinput_recovery_delay_for_test() noexcept; | ||
| 518 | |||
| 519 | /** | ||
| 520 | * @brief Returns whether permanent storage currently owns a primary raw hook. | ||
| 521 | * @details The result reports the permanent-retention latch on the canonical hook and keepalives. | ||
| 522 | * A false result does not describe backend storage. reset() can reclaim or retain that storage. | ||
| 523 | */ | ||
| 524 | [[nodiscard]] bool xinput_permanent_primary_retained() noexcept; | ||
| 525 | |||
| 526 | /** | ||
| 527 | * @brief Counts XInput keepalives: 0 with no detour, 2 for one target module, or 3 for a forwarded Ex target. | ||
| 528 | * @details A timeout or unproved restore leaves the same set in permanent storage. A clean teardown releases it. | ||
| 529 | * This seam excludes independent host pins on the XInput DLL. | ||
| 530 | */ | ||
| 531 | [[nodiscard]] int xinput_module_refs_held() noexcept; | ||
| 532 | |||
| 533 | /// Arms the B-100 process-exit oracle with a patched XInput target byte. | ||
| 534 | void arm_xinput_process_exit_oracle_for_test(const std::uint8_t *target) noexcept; | ||
| 535 | |||
| 536 | /** | ||
| 537 | * @brief Overrides the module install_xinput() resolves XInputGetState from, bypassing the DLL-name search. | ||
| 538 | * @details Lets a test select a synthetic proxy whose ordinal 100 is local or forwarded. Null clears the override. | ||
| 539 | * Compiled out of shipping archives. | ||
| 540 | */ | ||
| 541 | void set_xinput_module_override_for_test(HMODULE module) noexcept; | ||
| 542 | |||
| 543 | /** | ||
| 544 | * @brief Returns the saved original XInputGetStateEx (ordinal-100) trampoline, or nullptr when no chain exists. | ||
| 545 | * @details A committed arm keeps this non-null for callers admitted before its target became unreachable, even | ||
| 546 | * while the pair is degraded. Absent and aliased exports have no distinct chain. | ||
| 547 | */ | ||
| 548 | [[nodiscard]] XInputGetStateFn xinput_ex_trampoline() noexcept; | ||
| 549 | |||
| 550 | /// Applies the raw-XInput suppression gate to a synthetic state. | ||
| 551 | void apply_xinput_suppress_for_test(XINPUT_STATE *state, DWORD user_index) noexcept; | ||
| 552 | |||
| 553 | /// Returns the controller index most recently published by a successful install. | ||
| 554 | [[nodiscard]] int xinput_bound_user_index() noexcept; | ||
| 555 | |||
| 556 | /// Seam signature; see set_data_plane_entry_seam. | ||
| 557 | using DataPlaneEntrySeam = void (*)() noexcept; | ||
| 558 | |||
| 559 | /// Seam signature; see set_wheel_capture_entry_seam. | ||
| 560 | using WheelCaptureEntrySeam = void (*)() noexcept; | ||
| 561 | |||
| 562 | /** | ||
| 563 | * @brief Installs a probe that runs on entry to a data-plane operation, before it takes the data-plane lock. | ||
| 564 | * @details The only way to park a caller in the window between deciding to publish and being authorized to, | ||
| 565 | * which is what makes a revocation land against an already-entered publication on demand. Null clears it. | ||
| 566 | * Compiled out of shipping archives. | ||
| 567 | */ | ||
| 568 | void set_data_plane_entry_seam(DataPlaneEntrySeam seam) noexcept; | ||
| 569 | |||
| 570 | /** | ||
| 571 | * @brief Installs a probe that runs inside wheel count admission, after the capture sample and before the | ||
| 572 | * epoch-tagged fold. | ||
| 573 | * @details Lets a test park a callback frame inside the counted admission phase, or prove that owner revocation | ||
| 574 | * invalidates an already-entered capture without waiting for it and without polluting the successor's | ||
| 575 | * backlog. Null clears it. Compiled out of shipping archives. | ||
| 576 | */ | ||
| 577 | void set_wheel_capture_entry_seam(WheelCaptureEntrySeam seam) noexcept; | ||
| 578 | |||
| 579 | /// Seam signature; see set_wheel_finalize_entry_seam. | ||
| 580 | using WheelFinalizeEntrySeam = void (*)() noexcept; | ||
| 581 | |||
| 582 | /** | ||
| 583 | * @brief Installs a probe that runs inside wheel consume finalization, before its revalidation. | ||
| 584 | * @details Lets a test change focus, masks, or the epoch between count admission and the final WM_NULL write. | ||
| 585 | * Null clears it. Compiled out of shipping archives. | ||
| 586 | */ | ||
| 587 | void set_wheel_finalize_entry_seam(WheelFinalizeEntrySeam seam) noexcept; | ||
| 588 | |||
| 589 | /// Overrides the bounded wheel admitted-phase drain wait. Zero restores the default. Test-only. | ||
| 590 | void set_wheel_drain_timeout_for_test(std::uint64_t timeout_ms) noexcept; | ||
| 591 | |||
| 592 | /// Forces local message-hook removal to fail. False restores the OS call. Test-only. | ||
| 593 | void set_message_unhook_failure_for_test(bool fail) noexcept; | ||
| 594 | |||
| 595 | /// Overrides the wheel focus gate's foreground answer: 0 unfocused, 1 focused, negative restores the real query. | ||
| 596 | void set_wheel_process_focus_for_test(std::int32_t focused) noexcept; | ||
| 597 | |||
| 598 | /// Returns the count of resident callback frames currently inside a wheel admission phase. Test-only. | ||
| 599 | [[nodiscard]] std::uint32_t wheel_admitted_phases_for_test() noexcept; | ||
| 600 | |||
| 601 | /** | ||
| 602 | * @brief Runs the complete wheel-message path for one signed delta (T-WHEEL). | ||
| 603 | * @details Exactly the hook callback's handling: count admission (remainder accumulation under the live capture | ||
| 604 | * state and whole-notch publication into the drain counters), then the consume-finalization verdict | ||
| 605 | * against the published consume mask. | ||
| 606 | * @return true when the real hook callback would swallow the message. | ||
| 607 | */ | ||
| 608 | [[nodiscard]] bool process_wheel_message_for_test(bool horizontal, int delta) noexcept; | ||
| 609 | |||
| 610 | /** | ||
| 611 | * @brief Returns the consume-rule seqlock sequence. | ||
| 612 | * @details Odd means a write bracket is open. A refused publication must leave it even and unchanged, which is the | ||
| 613 | * observable difference between refusing before the bracket and rolling one back. | ||
| 614 | */ | ||
| 615 | [[nodiscard]] std::uint32_t consume_rules_sequence() noexcept; | ||
| 616 | |||
| 617 | /// Returns the reactive gamepad mask currently published to the detour. | ||
| 618 | [[nodiscard]] std::uint16_t gamepad_suppress_mask_for_test() noexcept; | ||
| 619 | |||
| 620 | /// Returns whether detour-side consume-rule evaluation is enabled. | ||
| 621 | [[nodiscard]] bool gamepad_rule_suppress_enabled_for_test() noexcept; | ||
| 622 | |||
| 623 | /// Returns the wheel-direction consume mask currently published to the wheel hook. | ||
| 624 | [[nodiscard]] std::uint8_t wheel_consume_mask_for_test() noexcept; | ||
| 625 | |||
| 626 | /** | ||
| 627 | * @brief Claims the idle layer for an arbitrary @p owner without installing a hook. | ||
| 628 | * @details Owner-scoped paths are otherwise only reachable by installing, which needs a live XInput module or a | ||
| 629 | * top-level window that a unit-test process may not have. This grants the lease alone so a white-box case | ||
| 630 | * can drive an owning poller's drain and publication paths on any host. Fails while another owner holds | ||
| 631 | * the layer. Release through uninstall(owner). Compiled out of shipping archives. | ||
| 632 | */ | ||
| 633 | [[nodiscard]] bool adopt_owner_for_test(std::uint64_t owner) noexcept; | ||
| 634 | #endif | ||
| 635 | |||
| 636 | } // namespace DetourModKit::detail | ||
| 637 | |||
| 638 | #endif // DETOURMODKIT_INTERNAL_INPUT_INTERCEPT_HPP | ||
| 639 |