GCC Code Coverage Report


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

src/internal/input_poller.hpp
Line Branch Exec Source
1 #ifndef DETOURMODKIT_INTERNAL_INPUT_POLLER_HPP
2 #define DETOURMODKIT_INTERNAL_INPUT_POLLER_HPP
3
4 /**
5 * @file input_poller.hpp
6 * @brief Internal background poll engine that drives the public input::Input facade.
7 * @details This is the RAII polling engine the input::Input facade owns. It monitors keyboard, mouse, gamepad, and
8 * mouse-wheel state on a dedicated thread, performs press/hold edge detection with strict modifier matching,
9 * and drives the opt-in interception layer (input_intercept.hpp). It is split out of the installed header so
10 * the facade carries no Win32 / threading state, and so the engine stays drivable white-box from the test
11 * suite. Not installed.
12 *
13 * The engine works with flat InputBinding records: one combo equals one binding entry, and the facade
14 * explodes a public input::ComboBinding into one entry per combo, all sharing a name (OR logic). The engine
15 * speaks the public input vocabulary (input::Trigger, input::KeyComboList, input::BindingToken) so the facade
16 * can pass them straight through.
17 */
18
19 #include "DetourModKit/input.hpp"
20 #include "DetourModKit/input_codes.hpp"
21 #include "internal/input_binding_gate.hpp"
22 #include "internal/input_binding_lifecycle.hpp"
23 #include "internal/input_intercept.hpp"
24 #include "internal/srw_shared_mutex.hpp"
25
26 #include <array>
27 #include <atomic>
28 #include <chrono>
29 #include <condition_variable>
30 #include <cstddef>
31 #include <cstdint>
32 #include <functional>
33 #include <memory>
34 #include <mutex>
35 #include <optional>
36 #include <string>
37 #include <string_view>
38 #include <thread>
39 #include <unordered_map>
40 #include <utility>
41 #include <vector>
42
43 namespace DetourModKit
44 {
45 namespace detail
46 {
47 /**
48 * @brief Allocates a fresh binding lifecycle at the next process-wide generation.
49 * @details The facade calls this in register_combo to share one block across a name's exploded engine entries
50 * and their gate; the engine reuses the block a binding already carries and only allocates for one
51 * seeded without. Defined in input_poller.cpp, which owns the monotonic generation counter.
52 * @note Also the point at which the delivery-scope TLS slot is reserved. A process with no slot left still
53 * registers bindings, but every delivery to them is then refused (see input_delivery_scope.hpp), so the
54 * failure is reported once through the log rather than surfacing as silently absent callbacks.
55 */
56 [[nodiscard]] std::shared_ptr<BindingLifecycle> make_binding_lifecycle();
57
58 /**
59 * @struct InputBinding
60 * @brief Engine record for a single input-to-action binding (one combo).
61 * @details Holds the action name, trigger and modifier codes, the trigger mode, the suppression opt-in, and the
62 * callbacks. The keys vector is OR logic (any single trigger fires); the modifiers vector is AND
63 * logic (all must be held). Modifier matching is strict across the whole binding set: any key used as
64 * a modifier in any binding blocks bindings that do not list it, so "V" does not fire while "Shift+V"
65 * is pressed.
66 *
67 * For Trigger::Press the press callback fires on the key-down edge; for Trigger::Hold the state
68 * callback fires true on press and false on release (including a synthesized false at shutdown for an
69 * active hold). All codes in one binding should come from the same device group; mouse-wheel codes are
70 * trigger-only and Press-mode (a notch reads as one Press edge). Callbacks run on the poll thread and
71 * must be quick.
72 *
73 * Entry destruction can invoke consumer capture destructors. Those destructors can reenter Input.
74 * A reshape moves dropped entries into an unlocked retirement batch. The facade stores only gate
75 * wrappers here, so an entry copy keeps the consumer callable alive until the last entry drops.
76 */
77 struct InputBinding
78 {
79 std::string name;
80 std::vector<InputCode> keys;
81 std::vector<InputCode> modifiers;
82 input::Trigger trigger = input::Trigger::Press;
83
84 // Opt-in passthrough suppression. Honored only for digital gamepad buttons (via the XInputGetState hook)
85 // and the mouse wheel (via the queue message hook); analog axes and keyboard/mouse buttons cannot be
86 // masked.
87 bool consume = false;
88
89 // Identity of the register_combo() call that produced this entry. A guard's teardown clears the consume
90 // flag by this id, not by name: an empty-name consume binding is legal (input.hpp) but is absent from the
91 // name-index (recompute skips empty names), so a name-keyed clear would silently miss it and leave
92 // suppression armed for the process lifetime. 0 is the "no owner" sentinel for config-seeded / test /
93 // direct-constructed bindings, which keep the by-name clear path; a by-owner clear with owner 0 is a no-op.
94 std::uint64_t consume_owner = 0;
95
96 std::function<void()> on_press;
97 std::function<void(bool)> on_state_change;
98
99 // Generation/tombstone for this binding's registration, consulted when a staged poll-cycle callback is
100 // dispatched so a remove / clear / cardinality-changing rebind that lands between staging and dispatch
101 // refuses the stale old-generation callback. register_combo shares one block across a name's exploded
102 // entries and their gate; the poller allocates one for any entry seeded without it (config-seeded or
103 // directly constructed). See BindingLifecycle.
104 std::shared_ptr<BindingLifecycle> lifecycle;
105
106 // Set when on_state_change is a self-deduplicating HoldGate wrapper (delivering released(false) with no
107 // live held(true) is a no-op). A tombstoning reshape (remove / clear) then publishes the balancing false
108 // unconditionally instead of gating on m_active_states, which the poll loop zeroes when it commits a cycle
109 // that staged a release edge, before dispatch: a remove / clear landing between that commit and the
110 // dispatch would otherwise read "not held", skip the synthesis, and have the staged release refused by the
111 // tombstone, stranding the consumer held.
112 // False for config-seeded or directly constructed raw callbacks, which are not self-balancing and keep the
113 // m_active_states gate.
114 bool release_is_idempotent = false;
115
116 // The gate the callbacks above dispatch through, when the facade built them. Erasing this entry drops one
117 // of the gate's two strong owners; the binding's BindingGuard holds the other and, through it, the
118 // consumer callback. Retirement before a Logic DLL unmaps has to reach the gate itself, so the entry keeps
119 // a direct handle rather than leaving it captured inside the wrappers. Null for config-seeded or directly
120 // constructed raw callbacks, which have no gate and no guard.
121 std::shared_ptr<BindingGate> gate;
122 };
123
124 /**
125 * @class InputPoller
126 * @brief RAII polling engine monitoring input state on a background thread.
127 * @details Manages a dedicated poll thread that reads keyboard/mouse via GetAsyncKeyState, gamepad via XInput,
128 * and the mouse wheel via a queue message hook. Supports press (edge-triggered) and hold
129 * (level-triggered) bindings with modifier combinations and optional foreground-focus gating. On
130 * shutdown, active holds receive a final on_state_change(false).
131 *
132 * @note Non-copyable, non-movable. Callbacks run on the poll thread.
133 * @warning Inside a DLL, shutdown() must run before DLL_PROCESS_DETACH. A poll-thread join under the Windows
134 * loader lock deadlocks. The loader-lock path detaches the poll thread and retains its module
135 * reference, which keeps the code mapped.
136 * @warning The interception layer uses state and hooks shared per linked DMK instance. Only one poller can use
137 * mouse-wheel capture or gamepad passthrough suppression at a time. The Input singleton is the
138 * intended owner. Purely observational pollers install nothing.
139 */
140 class InputPoller
141 {
142 public:
143 /**
144 * @brief Constructs a poller with the given bindings and tuning. The poll thread does not start until
145 * start().
146 * @param bindings Bindings to monitor (moved).
147 * @param poll_interval Time between cycles; clamped to [MIN_POLL_INTERVAL, MAX_POLL_INTERVAL].
148 * @param require_focus When true, key events are ignored unless this process owns the foreground window.
149 * @param gamepad_index XInput controller index (clamped 0-3).
150 * @param trigger_threshold Analog trigger deadzone (clamped 0-255).
151 * @param stick_threshold Thumbstick deadzone (clamped 0-32767).
152 * @param wheel_backend Wheel-capture source the poll loop drives.
153 * @param wheel_host Resident host table, consulted only for WheelBackend::ExternalHost. It must stay
154 * valid for the poller's lifetime; the poller does not own it.
155 * @param wheel_target_thread_id Explicit wheel target UI thread id, or zero for automatic discovery.
156 */
157 explicit InputPoller(
158 std::vector<InputBinding> bindings,
159 std::chrono::milliseconds poll_interval = input::DEFAULT_POLL_INTERVAL,
160 bool require_focus = true,
161 int gamepad_index = 0,
162 int trigger_threshold = GamepadCode::TriggerThreshold,
163 int stick_threshold = GamepadCode::StickThreshold,
164 input::Input::WheelBackend wheel_backend = input::Input::WheelBackend::MessageHook,
165 const WheelHostTable *wheel_host = nullptr,
166 std::uint32_t wheel_target_thread_id = 0
167 );
168
169 ~InputPoller() noexcept;
170
171 InputPoller(const InputPoller &) = delete;
172 InputPoller &operator=(const InputPoller &) = delete;
173 InputPoller(InputPoller &&) = delete;
174 InputPoller &operator=(InputPoller &&) = delete;
175
176 /**
177 * @brief Starts the poll thread.
178 * @details Safe to call only once; subsequent calls are ignored with a warning. Not thread-safe (the Input
179 * facade serializes start).
180 */
181 void start();
182
183 /**
184 * @brief Opens the configured external wheel-host lease before the poll thread starts.
185 * @return A wheel-host status code. Local backends and an already open lease return
186 * @ref DMK_WHEELHOST_OK.
187 */
188 [[nodiscard]] int32_t prepare_wheel_source() noexcept;
189
190 /// Returns true while the poll thread is running.
191 [[nodiscard]] bool is_running() const noexcept;
192
193 /// Number of registered bindings, under the binding reader lock.
194 [[nodiscard]] std::size_t binding_count() const noexcept;
195
196 /// Returns whether at least one binding uses @p name.
197 [[nodiscard]] bool has_bindings_by_name(std::string_view name) const noexcept;
198
199 /// The configured poll interval.
200 [[nodiscard]] std::chrono::milliseconds poll_interval() const noexcept;
201
202 /// The configured XInput controller index (0-3).
203 [[nodiscard]] int gamepad_index() const noexcept;
204
205 /// Queries activity by index. Returns false for out-of-range indices. Thread-safe.
206 [[nodiscard]] bool is_binding_active(std::size_t index) const noexcept;
207
208 /// Queries activity by name (OR over the name's combos). Returns false for an unknown name. Thread-safe.
209 [[nodiscard]] bool is_binding_active(std::string_view name) const noexcept;
210
211 /**
212 * @brief Resolves a name to a generation-checked token for repeated low-overhead queries.
213 * @return A valid token when the name is registered; an invalid token when unknown or on allocation
214 * failure.
215 * @note Setup/control-plane: copies the name's index set and may allocate.
216 */
217 [[nodiscard]] input::BindingToken acquire_binding_token(std::string_view name) const noexcept;
218
219 /// Queries a binding through a previously acquired token (the per-frame hot path). Fails closed when stale.
220 [[nodiscard]] bool is_binding_active(const input::BindingToken &token) const noexcept;
221
222 /// Reports whether a token still matches the live binding generation.
223 [[nodiscard]] bool binding_token_current(const input::BindingToken &token) const noexcept;
224
225 #ifdef DMK_ENABLE_TEST_SEAMS
226 /// Test-only: the interception-layer owner id this poller presents. Compiled out of shipping archives.
227 23 [[nodiscard]] std::uint64_t intercept_owner_for_test() const noexcept { return m_intercept_owner; }
228
229 /// Returns the test-only wheel-host diagnostic latch.
230 1 [[nodiscard]] std::int32_t wheel_host_logged_status_for_test() const noexcept
231 {
232 2 return m_wheel_host_logged_status.load(std::memory_order_relaxed);
233 }
234
235 /**
236 * @brief Test-only: runs the acquisition republish the poll loop performs on the cycle it first owns the
237 * layer.
238 * @details Lets a case observe the published table without starting a poll thread and without waiting for
239 * an install that a headless host may be unable to perform. It calls the same publication the poll
240 * loop calls, so the authorization it goes through is the real one. Compiled out of shipping
241 * archives.
242 */
243 void publish_consume_rules_for_test() noexcept;
244 #endif
245
246 /// Sets whether the poller gates on foreground focus. Thread-safe; takes effect immediately.
247 void set_require_focus(bool require_focus) noexcept;
248
249 /**
250 * @brief Sets the suppression flag on every binding sharing @p name and refreshes the interception gates.
251 * @details A no-op if the name was never registered. Thread-safe; safe while running.
252 */
253 void set_consume(std::string_view name, bool consume) noexcept;
254
255 /**
256 * @brief Sets the suppression flag on every binding stamped with @p owner and refreshes the gates.
257 * @details Identity-keyed counterpart to set_consume(name): matches on the register_combo() call id carried
258 * by InputBinding::consume_owner rather than the name, so it clears the consume flag of an
259 * empty-name binding (which is absent from the name index) and confines the clear to one
260 * registration's entries. A no-op when @p owner is 0 (the no-owner sentinel) or unmatched.
261 * Thread-safe; safe while running.
262 */
263 void set_consume_by_owner(std::uint64_t owner, bool consume) noexcept;
264
265 /**
266 * @brief Reports occupancy of the bounded same-frame gamepad-chord suppression table.
267 * @details Reflects THIS poller's own last publish, not the live instance-shared table, which a later
268 * engine or a test can have republished since. @c rejected is non-zero only when the eligible
269 * rule set outgrew the detour's storage; those chords keep the reactive (poll-published) mask and
270 * lose only the leading-edge protection.
271 */
272 [[nodiscard]] input::ConsumeCapacity consume_capacity() const noexcept;
273
274 /**
275 * @brief Reports the typed health of this poller's wheel route.
276 * @details The local backend rechecks target-thread liveness. The external backend reports the health
277 * derived from the last host route snapshot.
278 */
279 [[nodiscard]] input::Input::WheelSourceHealth wheel_source_health() const noexcept;
280
281 /**
282 * @brief Stops the poll thread.
283 * @details Joins and delivers final Hold releases. Idempotent.
284 * @note A poll-thread call only requests stop and makes self_retiring() true.
285 * @note Loader-lock or failed-join teardown keeps the owner, module reference, and detours retained.
286 */
287 void shutdown() noexcept;
288
289 /**
290 * @brief Reports that shutdown() was reached on the poll thread and could not finish there.
291 * @details True only after such a call. The owner must then hand its external reference to the lifecycle
292 * reaper instead of destroying the poller inline, because destroying it here would either
293 * self-join or free members the still running poll loop is reading. The reaper calls shutdown()
294 * again on its own thread, where the join, the detour uninstall, and the final
295 * on_state_change(false) rundown are safe, and releases its reference only once that returns.
296 */
297 193 [[nodiscard]] bool self_retiring() const noexcept
298 {
299 193 return m_self_retiring.load(std::memory_order_acquire);
300 }
301
302 /**
303 * @brief Reports that shutdown could not prove the poll thread stopped and the owner must be retained.
304 * @details Set on loader-lock detach and on a contained join failure. Destroying the poller after either
305 * path could free members a detached or still-joinable thread may still read.
306 */
307 8 [[nodiscard]] bool requires_abandonment() const noexcept
308 {
309 8 return m_requires_abandonment.load(std::memory_order_acquire);
310 }
311
312 /**
313 * @brief Precommits the owner reference used when shutdown cannot drain safely.
314 * @details Call once the worker is running and before the poller is reachable from another thread, so no
315 * teardown can find it unprotected and none has to allocate to retain it. The deliberate
316 * self-reference is cleared by shutdown() only after a completed join and rundown, or when there
317 * is no worker to run down at all. A poll-thread call returns with it still held, pending the
318 * off-thread re-entry that completes the rundown; the loader-lock, failed-join, and unaccepted
319 * retirement paths keep it permanently.
320 * @param owner The shared owner of this poller.
321 */
322 197 void retain_owner_for_abandonment(std::shared_ptr<InputPoller> owner) noexcept
323 {
324 197 m_owner_keepalive = std::move(owner);
325 197 }
326
327 /// Outcome of update_combos, so the facade can map each failure class to its promised error code.
328 enum class ComboUpdate : std::uint8_t
329 {
330 /// The swap committed (including the unbind sentinel case).
331 Updated,
332 /// The name was never registered.
333 NameAbsent,
334 /// A resource acquisition failed before the commit.
335 ResourceFailure,
336 };
337
338 /**
339 * @brief Replaces the trigger combos of all bindings sharing @p name.
340 * @details Matching-count updates preserve held state and dispatch no release callback. Cardinality changes
341 * rebuild the entry set with the same callbacks, mode, and name. They dispatch
342 * on_state_change(false) for held bindings after the replacement commit and binding-lock release.
343 * An empty list leaves one inert sentinel so the name stays addressable. The operation is safe
344 * while the poll thread runs.
345 * @return The @ref ComboUpdate outcome. Both failure outcomes leave bindings, active states, callbacks,
346 * and generations unchanged.
347 */
348 [[nodiscard]] ComboUpdate update_combos(std::string_view name, const input::KeyComboList &combos) noexcept;
349
350 /**
351 * @brief Appends a binding to the running poller, carrying surviving entries' active state forward.
352 * @return true when the binding was appended; false when growing the engine failed under host OOM (the
353 * poller is left exactly as it was). The caller surfaces the failure rather than silently
354 * committing a subset of a multi-combo registration.
355 */
356 [[nodiscard]] bool add_binding(InputBinding binding) noexcept;
357
358 /**
359 * @brief Appends a batch of bindings atomically, carrying surviving entries' active state forward.
360 * @return true when every binding was appended; false when growing the engine failed under host OOM (the
361 * poller is left exactly as it was).
362 */
363 [[nodiscard]] bool add_bindings(std::vector<InputBinding> bindings) noexcept;
364
365 /// Removes every binding sharing @p name (invoking hold-release callbacks). Returns the count removed.
366 5 std::size_t remove_bindings_by_name(std::string_view name) noexcept
367 {
368 5 return remove_bindings_by_name(name, true);
369 }
370
371 /// Drops every binding without stopping the thread (invoking hold-release callbacks).
372 2 void clear_bindings() noexcept { clear_bindings(true); }
373
374 /**
375 * @brief Variant of remove_bindings_by_name that can suppress the hold-release callbacks.
376 * @param invoke_callbacks When false, the on_state_change(false) callbacks are dropped because the hosting
377 * Logic DLL's pages may be unmapping, and the in-flight rundown is skipped: an
378 * unload caller either must not block at all (loader lock) or owns its own bounded
379 * wait (the typed drain).
380 */
381 std::size_t remove_bindings_by_name(std::string_view name, bool invoke_callbacks) noexcept;
382
383 /// Variant of clear_bindings carrying the same invoke_callbacks contract as remove_bindings_by_name.
384 void clear_bindings(bool invoke_callbacks) noexcept;
385
386 /**
387 * @brief Retires the gates of every binding sharing @p name ahead of removing them for a Logic DLL unload.
388 * @param deadline Bound on the wait for an in-flight delivery to unwind, per gate.
389 * @return False when a gate could not be quiesced before @p deadline, in which case its callback is still
390 * alive, and also when the handles could not be collected at all because the collection ran out of
391 * memory. Retirement did not happen on either path, so the caller must not report the callbacks
392 * gone.
393 * @details Removal alone drops only the engine's owner of the gate, leaving the consumer callback alive
394 * inside a retained BindingGuard. This runs first so a still-held hold's balancing edge is
395 * delivered while the DLL is mapped and every gate-owned callback is destroyed here.
396 * @warning Control-plane only, and callers must be off any delivery. Runs consumer code.
397 */
398 [[nodiscard]] bool
399 retire_gates_by_name(std::string_view name, std::chrono::steady_clock::time_point deadline) noexcept;
400
401 /// retire_gates_by_name over every binding, for the retire-everything drain.
402 [[nodiscard]] bool retire_all_gates(std::chrono::steady_clock::time_point deadline) noexcept;
403
404 private:
405 /// Shared tail of the two retire entry points: retires each collected gate off the binding lock.
406 [[nodiscard]] bool retire_collected_gates(
407 const std::vector<std::shared_ptr<BindingGate>> &gates,
408 std::chrono::steady_clock::time_point deadline
409 ) noexcept;
410
411 void poll_loop(std::stop_token stop_token);
412 void release_active_holds() noexcept;
413 [[nodiscard]] bool is_process_foreground() const noexcept;
414
415 /// Transparent hasher enabling std::string_view lookup without allocation.
416 struct StringHash
417 {
418 using is_transparent = void;
419 238090 std::size_t operator()(std::string_view sv) const noexcept { return std::hash<std::string_view>{}(sv); }
420 };
421
422 /**
423 * @struct ModifierCaches
424 * @brief Fallible derived state that a binding transaction prepares before its commit.
425 */
426 struct ModifierCaches
427 {
428 std::unordered_map<std::string, std::vector<std::size_t>, StringHash, std::equal_to<>> name_index;
429 std::vector<InputCode> known_modifiers;
430 std::vector<GamepadConsumeRule> consume_rules;
431 bool has_gamepad_bindings{false};
432 bool has_wheel_bindings{false};
433 bool has_consume_gamepad_bindings{false};
434 };
435
436 /**
437 * @enum CacheFailPolicy
438 * @brief What a failed derived-cache rebuild leaves behind.
439 */
440 enum class CacheFailPolicy : std::uint8_t
441 {
442 /**
443 * @brief Clear every derived cache.
444 * @details For a caller that already reshaped m_bindings. The prior name index maps names to old
445 * positions, so retaining it could address past the new binding array; empty is the only
446 * index-safe answer.
447 */
448 ClearIndexSafe,
449 /**
450 * @brief Keep the previous lookup caches, but still disarm gamepad consume suppression.
451 * @details For a caller that changed only a flag on an existing binding. Cardinality, order, and names
452 * are untouched, so the name and modifier caches still describe m_bindings exactly and
453 * discarding them would disable name lookup, and widen firing by emptying the strict-match
454 * modifier set, over a change that invalidated neither. Suppression is not retained: the flag
455 * change may have been a retirement, and a retained rule list would outlive the binding that
456 * owned it.
457 */
458 Retain
459 };
460
461 /**
462 * @struct DeferredDiagnostics
463 * @brief Diagnostics collected under m_bindings_rw_mutex and emitted after release.
464 * @details The failure paths collect each diagnostic in fixed storage because collection must not allocate.
465 * Release m_bindings_rw_mutex before @ref emit.
466 * Emission under the exclusive lock extends sink latency into every callback-safe query that
467 * shares the lock. InputPollerTest.CallbackSafeQueryCompletesWhileConsumeDiagnosticSinkIsBlocked
468 * pins the order.
469 */
470 struct DeferredDiagnostics
471 {
472 bool cache_rebuild_retained = false;
473 bool cache_rebuild_cleared = false;
474 bool add_binding_oom = false;
475 bool add_bindings_oom = false;
476 bool consume_bound = false;
477 std::size_t consume_rejected = 0;
478 std::size_t consume_total = 0;
479
480 /// Emits every latched message. Call after m_bindings_rw_mutex is released.
481 void emit() const noexcept;
482 };
483
484 void recompute_modifier_caches_locked(
485 DeferredDiagnostics &diagnostics,
486 CacheFailPolicy policy = CacheFailPolicy::ClearIndexSafe
487 ) noexcept;
488
489 /**
490 * @brief Builds all fallible derived state for @p bindings without a member-state change.
491 * @return The complete cache transaction, or std::nullopt after a resource failure.
492 */
493 [[nodiscard]] static std::optional<ModifierCaches>
494 build_modifier_caches(const std::vector<InputBinding> &bindings) noexcept;
495
496 /**
497 * @brief Publishes a complete cache transaction for the current binding set.
498 * @details Requires m_bindings_rw_mutex. The function performs no fallible allocation.
499 */
500 void commit_modifier_caches_locked(ModifierCaches &caches, DeferredDiagnostics &diagnostics) noexcept;
501
502 void record_consume_capacity(
503 std::size_t active,
504 std::size_t rejected,
505 DeferredDiagnostics &diagnostics
506 ) noexcept;
507
508 /**
509 * @brief Offers @ref m_consume_rules to the interception layer and records the resulting occupancy.
510 * @details Requires m_bindings_rw_mutex. A refusal means this poller does not hold the layer; the rules
511 * stay cached and @ref m_consume_rules_unpublished latches so the poll loop retries on
512 * acquisition. Capacity diagnostics land in @p diagnostics for the caller to emit off the lock.
513 */
514 void publish_consume_rules_locked(DeferredDiagnostics &diagnostics) noexcept;
515
516 // m_bindings_rw_mutex protects m_bindings, m_name_index, m_known_modifiers, m_binding_generation, and the
517 // interception gates during a live update. The poll loop holds a shared lock across the evaluation pass and
518 // releases it before dispatching callbacks, so callbacks may re-enter binding_count / is_binding_active /
519 // update_combos without re-acquiring the non-recursive lock; update_combos holds an exclusive lock. The
520 // m_active_states entries are always accessed via atomics and need no further guard.
521 mutable SrwSharedMutex m_bindings_rw_mutex;
522 std::vector<InputBinding> m_bindings;
523 std::unordered_map<std::string, std::vector<std::size_t>, StringHash, std::equal_to<>> m_name_index;
524 std::vector<InputCode> m_known_modifiers;
525 // Advances on every binding-set reshape; an input::BindingToken captures this at acquire time and a query
526 // whose token generation no longer matches fails closed. Guarded by m_bindings_rw_mutex.
527 std::uint64_t m_binding_generation{0};
528 std::chrono::milliseconds m_poll_interval;
529 std::atomic<bool> m_require_focus;
530 std::atomic<bool> m_running{false};
531 // Set when shutdown() ran on the poll thread and deferred the rundown. See self_retiring().
532 std::atomic<bool> m_self_retiring{false};
533 // Set when shutdown() cannot prove that owner destruction is safe. See requires_abandonment().
534 std::atomic<bool> m_requires_abandonment{false};
535 // Precommitted retention for loader-lock, failed-join, or failed-reaper paths.
536 std::shared_ptr<InputPoller> m_owner_keepalive;
537 std::jthread m_poll_thread;
538 // Counted reference on the module the poll thread's code lives in, taken before the thread is created
539 // while the module is fully mapped. shutdown() releases it after a clean join, or leaks it on the
540 // loader-lock detach path so the poll-loop code and the detours left installed against this module stay
541 // mapped. void* keeps this header free of <windows.h>; it holds an HMODULE in the implementation. See
542 // detail::acquire_module_ref.
543 void *m_self_ref{nullptr};
544 std::mutex m_cv_mutex;
545 std::condition_variable_any m_cv;
546
547 // Per-binding active state, indexed parallel to m_bindings. Atomic for cross-thread reads.
548 std::unique_ptr<std::atomic<std::uint8_t>[]> m_active_states;
549
550 int m_gamepad_index;
551 int m_trigger_threshold;
552 int m_stick_threshold;
553 // Stable across poll-thread installation and off-thread teardown so only this poller can remove its hooks.
554 const std::uint64_t m_intercept_owner;
555 std::atomic<bool> m_has_gamepad_bindings{false};
556
557 // Wheel-capture backend chosen at construction. MessageHook installs a local source and shares the
558 // interception layer's owner, epoch, and drain path; ExternalHost drives the loader's resident host
559 // through the C ABI and holds a lease instead. Lease access is sequenced, never concurrent:
560 // prepare_wheel_source() runs before the poll thread starts, the poll thread reads while it runs, and
561 // shutdown() closes once the thread is joined or never started. A detach-abandoned teardown keeps the
562 // lease open because the detached thread can still read it.
563 const input::Input::WheelBackend m_wheel_backend;
564 const WheelHostTable *const m_wheel_host;
565 // Explicit target pin from Settings. Zero selects automatic foreground discovery with migration.
566 const std::uint32_t m_wheel_target_thread_id;
567 WheelHostLease m_wheel_lease{0};
568 std::uint64_t m_wheel_lease_generation{0};
569 std::atomic<bool> m_external_lease_active{false};
570 std::atomic<bool> m_external_wheel_discard_pending{false};
571 // Health derived from the last host route snapshot, latched for the off-thread health query. The local
572 // backend derives its state from the interception layer instead.
573 std::atomic<input::Input::WheelSourceHealth> m_external_health{input::Input::WheelSourceHealth::TargetWait};
574 // One log line per distinct latched host status. Health carries the live state; this only gates the log.
575 std::atomic<std::int32_t> m_wheel_host_logged_status{0};
576
577 // The wheel-source helpers below dispatch on m_wheel_backend so the poll loop stays backend-agnostic.
578 // wheel_source_maintain runs once per cycle while wheel bindings exist: it resolves the desired target
579 // (explicit pin or process-owned foreground thread), mounts an absent route, migrates a moved one, pays
580 // an owed retarget retry, and latches the derived health.
581 void wheel_source_maintain() noexcept;
582 [[nodiscard]] std::uint32_t resolve_wheel_target() const noexcept;
583 [[nodiscard]] std::array<int, 4> wheel_source_take_counts() noexcept;
584 void wheel_source_publish_consume(std::uint8_t direction_mask, bool capture_enabled) noexcept;
585 void wheel_source_close() noexcept;
586 void note_wheel_host_status(std::int32_t status, const char *operation) noexcept;
587
588 // Interception gates, recomputed alongside the modifier caches. Each lazily installs an active-input hook
589 // from the poll loop, so a mod that never opts in pays no interception cost.
590 std::atomic<bool> m_has_wheel_bindings{false}; // any MouseWheel trigger -> queue hook
591 std::atomic<bool> m_has_consume_gamepad_bindings{false}; // any consume gamepad binding -> XInput hook
592
593 // The consume rules this poller's current binding set calls for, kept whether or not it may publish them.
594 // A rebuild runs wherever a binding changes (including in the constructor, before this poller claims
595 // the interception layer), and publishing there overwrites the rules of whichever poller actually
596 // owns the layer. Guarded by m_bindings_rw_mutex.
597 std::vector<GamepadConsumeRule> m_consume_rules;
598 // Set when a rebuild could not publish because this poller did not hold the layer. The poll loop
599 // republishes once on the cycle that observes ownership and clears it, so acquisition does not inherit
600 // whatever the previous owner left behind.
601 std::atomic<bool> m_consume_rules_unpublished{true};
602
603 // Eligible rules OFFERED to the last publish, which is active + rejected. One atomic keeps the pair a
604 // caller reads coherent without making the callback-safe capacity query contend for the binding lock.
605 std::atomic<std::size_t> m_consume_rules_total{0};
606
607 // One over-capacity warning per engine; see record_consume_capacity.
608 std::atomic<bool> m_consume_bound_reported{false};
609 };
610
611 #ifdef DMK_ENABLE_TEST_SEAMS
612 // Test seams compiled out of shipping archives. They make the staging and admission windows deterministic.
613 //
614 // Publication rule, binding on every consumer: install a seam only while the engine is stopped, and clear it
615 // only after the poll thread and any deferred reaper have finished. The poll thread reads these objects
616 // without synchronization, so replacing one under a live loop destroys a callable mid-call; a host that
617 // cleared before joining turned its own diagnostic into an access violation. shutdown() reached from a
618 // binding callback does not close the window on return (the rundown is handed to the reaper and completes
619 // off-thread), so the caller needs its own completion signal before clearing.
620 // tests/lifecycle/input_seam_cleanup.hpp owns this order for the raw hosts.
621 //
622 // g_input_key_state_probe: when set, replaces GetAsyncKeyState as the keyboard/mouse down-state source, so a
623 // test can raise a press/hold edge without synthesizing real OS input. Must not throw.
624 //
625 // g_input_post_stage_probe: runs after staging and before admission, receiving the staged-callback count.
626 //
627 // g_input_pre_dispatch_probe: runs after admission and before the callback begins.
628 //
629 // g_input_join_fail_seam: a throwing probe exercises shutdown()'s join-failure containment.
630 //
631 // g_input_external_wheel_post_drain_probe: runs on the poll thread after the external host counts are drained
632 // and before the evaluation lock, receiving the drained counts. Makes the drain-to-evaluation reshape window
633 // deterministic.
634 extern std::function<bool(int)> g_input_key_state_probe;
635 extern std::function<void(std::size_t)> g_input_post_stage_probe;
636 extern std::function<void()> g_input_pre_dispatch_probe;
637 extern void (*g_input_join_fail_seam)();
638 extern std::function<void(const std::array<int, 4> &)> g_input_external_wheel_post_drain_probe;
639 #endif
640 } // namespace detail
641 } // namespace DetourModKit
642
643 #endif // DETOURMODKIT_INTERNAL_INPUT_POLLER_HPP
644