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