GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 100.0% 2 / 0 / 2
Functions: 100.0% 1 / 0 / 1
Branches: -% 0 / 0 / 0

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> &notches) 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