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