./
Coverage:
low: ≥ 0%
medium: ≥ 75.0%
high: ≥ 90.0%
Lines:
2 / 2
100.0%
Branches:
-
-%
Functions:
1 / 1
100.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 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> &notches) 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