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: 80.0% 4 / 0 / 5

include/DetourModKit/input.hpp
Line Branch Exec Source
1 #ifndef DETOURMODKIT_INPUT_HPP
2 #define DETOURMODKIT_INPUT_HPP
3
4 /**
5 * @file input.hpp
6 * @brief Hotkey and gamepad input surface: combo bindings, edge detection, and opt-in passthrough suppression.
7 * @details A binding is registered once as a ComboBinding and owned by a move-only BindingGuard. A Scope batches
8 * guards and releases them in reverse insertion order. The Input facade owns a single background poll thread
9 * and the interception layer (one XInput hook plus one thread-scoped wheel message hook, shared per linked
10 * DMK instance).
11 * @warning `[B-100]` Run registration and Input::start() outside the loader lock. Registration allocates, and start()
12 * creates the poll thread. The loader-lock shutdown path vetoes the join and detaches the thread.
13 */
14
15 #include "DetourModKit/error.hpp"
16 #include "DetourModKit/input_codes.hpp"
17 // Deliberate full include, not a forward declaration: Settings names the host table type, so this installed header
18 // stays self-contained for ExternalHost consumers. The ABI header is C-only and windows.h-free.
19 #include "DetourModKit/abi/wheel_host.h"
20
21 #include <chrono>
22 #include <cstddef>
23 #include <cstdint>
24 #include <functional>
25 #include <memory>
26 #include <span>
27 #include <string>
28 #include <string_view>
29 #include <vector>
30
31 namespace DetourModKit
32 {
33 namespace detail
34 {
35 // The poll/edge-detection engine (src/internal/input_poller.hpp). It stays incomplete so BindingToken can grant
36 // it friendship. Input's private accessors can return one while its layout stays hidden.
37 class InputPoller;
38
39 // Test-only white-box accessor over the Input facade (src/internal/input_test_seams.hpp), so this installed
40 // definition carries no macro-dependent member.
41 struct InputTestSeams;
42 } // namespace detail
43
44 namespace input
45 {
46 /**
47 * @struct KeyCombo
48 * @brief One alternative key combination: OR across keys, AND across modifiers.
49 * @details Keep all codes in one combo in the same device group (keyboard/mouse or gamepad). Mouse-wheel
50 * codes are a standalone, trigger-only source.
51 */
52 struct KeyCombo
53 {
54 std::vector<InputCode> keys;
55 std::vector<InputCode> modifiers;
56 };
57
58 /// A list of alternative key combinations (OR logic between combos). An empty list means "no keys bound".
59 using KeyComboList = std::vector<KeyCombo>;
60
61 /**
62 * @enum Trigger
63 * @brief Edge model for a binding.
64 */
65 enum class Trigger : std::uint8_t
66 {
67 /// Fires the on_press callback once per key-down edge.
68 Press,
69 /**
70 * @brief Level model that fires on both the press and release edges.
71 * @details Fires on_state_change(true) on the press edge and on_state_change(false) on the release edge.
72 * The release edge is synthesized exactly once on teardown for a binding still held at shutdown.
73 */
74 Hold
75 };
76
77 /**
78 * @enum CallbackDrainStatus
79 * @brief Typed outcome of an off-loader-lock input callback rundown.
80 */
81 enum class CallbackDrainStatus : std::uint8_t
82 {
83 /// Every selected binding was retired, its callback destroyed, and all staged callable storage destroyed.
84 Drained,
85 /// The deadline expired while staged callable storage or a callback body remained alive.
86 TimedOut,
87 /// The caller is running inside an input callback and cannot wait for its own storage lease.
88 SelfDelivery,
89 /// Another control thread owns the drain transaction.
90 InProgress,
91 /// At least one selected binding was not retired.
92 RetireFailed
93 };
94
95 /**
96 * @brief Converts a Trigger to its string representation.
97 * @param trigger The trigger mode.
98 * @return String form ("Press" / "Hold"), or "Unknown" for an out-of-range value.
99 */
100 218 [[nodiscard]] constexpr std::string_view to_string(Trigger trigger) noexcept
101 {
102
3/3
✓ Branch 2 → 3 taken 187 times.
✓ Branch 2 → 4 taken 30 times.
✓ Branch 2 → 5 taken 1 time.
218 switch (trigger)
103 {
104 187 case Trigger::Press:
105 187 return "Press";
106 30 case Trigger::Hold:
107 30 return "Hold";
108 }
109 1 return "Unknown";
110 }
111
112 /// Default poll cadence: 16 ms (~60 Hz), matching a typical frame budget.
113 inline constexpr std::chrono::milliseconds DEFAULT_POLL_INTERVAL{16};
114 /// Lower clamp for the poll interval.
115 inline constexpr std::chrono::milliseconds MIN_POLL_INTERVAL{1};
116 /// Upper clamp for the poll interval.
117 inline constexpr std::chrono::milliseconds MAX_POLL_INTERVAL{1000};
118
119 /**
120 * @struct ComboBinding
121 * @brief Declarative description of one named input binding, registered via register_combo.
122 * @details One register_combo call materializes one binding entry per combo, all sharing the name, so a
123 * name-based query is true when any of its combos is pressed.
124 *
125 * Modifier matching is strict across the whole binding set: any key that appears as a modifier in any
126 * registered binding blocks bindings that do not list it, so "V" cannot fire when "Shift+V" is
127 * pressed.
128 * @warning Callbacks run on the poll thread. They must execute quickly and must not capture references to
129 * objects whose lifetime ends before the owning BindingGuard is released or the Input facade is shut
130 * down.
131 */
132 struct ComboBinding
133 {
134 /**
135 * @brief Binding name; the key for is_active and rebind.
136 * @details A shared name groups multiple combos under OR logic. An empty name registers a binding
137 * addressable only through its guard, not by name.
138 */
139 std::string name = {};
140
141 /// Press or Hold edge model.
142 Trigger trigger = Trigger::Press;
143
144 /**
145 * @brief The combo alternatives, matched with OR between combos.
146 * @details Empty registers an inert, addressable binding that a later rebind can populate.
147 */
148 KeyComboList combos = {};
149
150 /**
151 * @brief Opt-in passthrough suppression that hides the trigger from the game while the guard is held.
152 * @details Honored only for digital gamepad buttons (XInputGetState hook) and the mouse wheel (queue
153 * message hook). Analog triggers, stick directions, keyboard keys, and mouse buttons cannot be
154 * masked. Wheel consume is best effort by mechanism: a hook installed after DMK can restore the
155 * wheel message after DMK returns.
156 *
157 * Gamepad suppression has two tiers. Every consume chord gets the reactive mask, which hides the
158 * trigger once the poll thread observes the chord. Same-frame suppression, which also closes the
159 * window where a modifier and its trigger go down inside one poll interval, comes from a
160 * fixed-size table. Shapes beyond it keep only the reactive mask, and Input::consume_capacity
161 * reports whether any did. Suppression is all-or-nothing across the pad entry points and fails
162 * open. If coverage of any entry point stops, suppression stops on all entry points until the
163 * complete coverage returns. Best-effort by contract: it may lapse without notice. A binding
164 * must tolerate game access to its trigger.
165 */
166 bool consume = false;
167
168 /// Invoked on the key-down edge when trigger == Press. Empty for a Hold binding.
169 std::function<void()> on_press = {};
170
171 /**
172 * @brief Hold-state callback, invoked when trigger is Hold.
173 * @details Invoked with the hold state (true held / false released). Empty for a Press binding.
174 */
175 std::function<void(bool)> on_state_change = {};
176 };
177
178 /**
179 * @struct ConsumeCapacity
180 * @brief Occupancy of the bounded same-frame gamepad-chord suppression table.
181 * @details Registration never fails on this bound. Ask here whether a set fits. @ref ConsumeCapacity::rejected
182 * counts the distinct chord shapes the table did not hold (see @ref ComboBinding::consume). Exact
183 * duplicate shapes share one entry, so @ref ConsumeCapacity::active counts shapes, not bindings.
184 */
185 struct ConsumeCapacity
186 {
187 /// Distinct chord shapes the live table can hold, or zero while no engine is active.
188 std::size_t capacity{0};
189 /**
190 * @brief Shapes currently published to the hook.
191 * @details Reads zero whenever any registered modifier anywhere in the binding set is not a digital
192 * gamepad button, because the hook cannot reproduce strict matching for any chord in that case.
193 * That loss is not a capacity shortfall, so @ref rejected does not count it.
194 */
195 std::size_t active{0};
196 /// Eligible shapes the bound turned away. Non-zero means some chords lost same-frame suppression.
197 std::size_t rejected{0};
198 };
199
200 /**
201 * @class BindingToken
202 * @brief Generation-checked handle to a named binding's resolved entry set for low-overhead repeated queries.
203 * @details A reshape that alters the binding SET advances the generation. These reshapes are register,
204 * name-based rebind, name-based removal, clear, or a real consume-flag transition. A stale token then
205 * fails closed and reads inactive. A consume set that re-applies the current flag value is a no-op and
206 * keeps every live token current. A plain guard release does NOT advance it. A consume binding's
207 * release advances it through the consume-flag transition. The counter is process-wide and monotonic,
208 * so a token cannot alias a different engine after a shutdown / start cycle. Default, unknown-name,
209 * and allocation-failed tokens are all invalid and always read inactive.
210 */
211 class BindingToken
212 {
213 public:
214 5 BindingToken() = default;
215
216 /**
217 * @brief Reports whether the token resolved a name at acquisition time.
218 * @details true only means acquire_token found the name and resolved its entries; it does NOT imply the
219 * token is still current. Use Input::token_current, or the fail-closed is_active(token), to test
220 * currency after a possible reshape.
221 * @return true for a resolved token; false for a default, unknown-name, or allocation-failed token.
222 */
223 143 [[nodiscard]] bool valid() const noexcept { return m_generation != 0; }
224
225 private:
226 friend class DetourModKit::detail::InputPoller;
227
228 // 0 marks an unresolved token. The process-wide counter starts at 1, so it can never collide.
229 std::uint64_t m_generation{0};
230
231 // Read only while m_generation still matches the engine's, which is what keeps these in bounds.
232 std::vector<std::size_t> m_indices;
233 };
234
235 /**
236 * @class BindingGuard
237 * @brief Move-only RAII cancellation token for a binding from register_combo or config::press_combo /
238 * hold_combo.
239 * @details Release (or destruction) gates the user callback off. The binding itself stays registered, because
240 * per-binding removal is not offered post-start. See @ref Input::remove_bindings_by_name for
241 * name-scoped removal.
242 *
243 * Release from OUTSIDE a callback runs down delivery in flight: once it returns, no callback for this
244 * binding is running or can start, and no other thread is still inside this binding's teardown
245 * consumer code, including the destructors of whatever a retired callable captured. The caller may
246 * then destroy state a callback captured, unconditionally. Release from INSIDE a callback cannot
247 * block without deadlocking two interdependent teardowns, so it marks the target gate released. A
248 * balancing edge runs inline or defers to the in-flight delivery's unwind. A caller using that
249 * pattern must not assume a delivery or teardown on another thread has finished when release()
250 * returns.
251 *
252 * A Hold guard synthesizes one balancing on_state_change(false) when a true edge was the last one
253 * forwarded, and never re-enters a callback that is on the stack. A consume binding's release also
254 * clears its engine-side consume flag, as set_consume(name, false) does.
255 *
256 * A guard release may race prepare_logic_dll_unload. Release and retirement exclude each other, so
257 * the rundown promise holds in both directions. Retirement disposes of the callable; an ordinary
258 * release leaves it gate-owned. A guard outliving the drain stays valid but no longer reaches the
259 * callback. Such a release still clears a consume binding's engine-side flag.
260 * @note Setup/control-plane only: destroy a guard from init / shutdown / a worker thread, never from a hook
261 * or input callback.
262 * @warning release may invoke a Hold binding's balancing callback and may block on the poll thread, or on a
263 * concurrent prepare_logic_dll_unload, for as long as your own balancing callback and capture
264 * destructors take. Neither wait is bounded, and the deadlock escape for a release reached from
265 * inside a callback is per-thread. Never destroy a guard while holding a lock, or owning a join, that
266 * any of that callback or destructor code can wait on.
267 */
268 class BindingGuard
269 {
270 public:
271 // Defined out-of-line in input.cpp: an inline-defaulted ctor instantiates ~unique_ptr<Impl> against the
272 // still-incomplete Impl.
273 BindingGuard() noexcept;
274 ~BindingGuard() noexcept;
275
276 BindingGuard(BindingGuard &&other) noexcept;
277 BindingGuard &operator=(BindingGuard &&other) noexcept;
278 BindingGuard(const BindingGuard &) = delete;
279 BindingGuard &operator=(const BindingGuard &) = delete;
280
281 /// Disables the binding's callback, then runs the binding teardown action once. Idempotent.
282 void release() noexcept;
283
284 /**
285 * @brief Returns true while this guard's binding callback remains enabled.
286 * @details Returns false after release or move. It also returns false after
287 * prepare_logic_dll_unload() retires the binding.
288 */
289 [[nodiscard]] bool is_active() const noexcept;
290
291 /// Returns the binding name this guard gates, or an empty view for an inert or moved-from guard.
292 [[nodiscard]] std::string_view name() const noexcept;
293
294 private:
295 friend class Input;
296 // pimpl: the shared cancellation flag, the binding name, and the teardown action. Defined in src/input.cpp
297 // so the OS-free header carries no engine type.
298 struct Impl;
299 explicit BindingGuard(std::unique_ptr<Impl> impl) noexcept;
300 std::unique_ptr<Impl> m_impl;
301 };
302
303 /**
304 * @class Scope
305 * @brief Owns a batch of BindingGuards and releases them in reverse insertion order on clear / destruction.
306 * @details Because a Hold guard can synthesize a balancing on_state_change(false) on release, the reverse order
307 * is a behavioral contract a consumer can rely on, not incidental member cleanup: a later binding that
308 * depends on an earlier one unwinds first. Guard release may also block behind an in-flight callback;
309 * see BindingGuard.
310 * @note Move-only. Destroy a Scope from setup/control-plane code (see BindingGuard).
311 */
312 class Scope
313 {
314 public:
315 190 Scope() = default;
316 558 ~Scope() noexcept { clear(); }
317
318 368 Scope(Scope &&) noexcept = default;
319 Scope &operator=(Scope &&) noexcept;
320 Scope(const Scope &) = delete;
321 Scope &operator=(const Scope &) = delete;
322
323 /// Takes ownership of a guard. An inert guard is stored harmlessly.
324 void add(BindingGuard guard);
325
326 /**
327 * @brief Releases the current guard batch in reverse insertion order. Idempotent.
328 * @details A reentrant add remains in this Scope for the next clear.
329 * @note Setup/control-plane only: the release runs consumer callbacks on the calling thread.
330 */
331 void clear() noexcept;
332
333 /**
334 * @brief Abandons every owned guard without running release or destruction. Idempotent. Process-death only.
335 * @details Retains the complete guard container without destroying callback captures, taking a gate mutex,
336 * or synthesizing a balancing on_state_change(false). Use this only when the owning object is
337 * being abandoned during process teardown (see Session::abandon), where running release logic or
338 * consumer destructors inside DllMain is unsafe. For an ordinary live teardown use clear().
339 * @note Setup/control-plane only: a process-teardown path (see details).
340 */
341 void abandon() noexcept;
342
343 /// Number of guards currently owned.
344
1/2
✓ Branch 3 → 4 taken 2 times.
✗ Branch 3 → 6 not taken.
2 [[nodiscard]] std::size_t size() const noexcept { return m_guards ? m_guards->size() : 0; }
345
346 private:
347 // Heap ownership is precommitted when the first guard is added, so abandon() can retain the complete
348 // container with unique_ptr::release and no allocation or destruction on the process-detach path.
349 std::unique_ptr<std::vector<BindingGuard>> m_guards;
350 };
351
352 /**
353 * @class Input
354 * @brief Process singleton that owns the poll thread, the binding set, and the interception layer.
355 * @details Bindings may be registered before or after start(): one made while the engine runs joins the live
356 * set and fires on the next cycle, one made before the engine exists is staged. The interception
357 * layer is shared per linked DMK instance and single-owner.
358 * @note MessageHook keeps this module mapped after its first successful publication.
359 * @note ExternalHost keeps the wheel hook and its module reference in the loader module.
360 */
361 class Input
362 {
363 public:
364 /**
365 * @enum WheelBackend
366 * @brief Selects the source the engine uses to capture mouse-wheel notches.
367 * @details The wheel has no virtual-key code, so a mouse-wheel binding needs a message source. Both
368 * backends see WM_MOUSEWHEEL / WM_MOUSEHWHEEL records removed from one selected UI-thread
369 * queue. Direct sent delivery, later DefWindowProc parent delivery, raw-input-only paths, and
370 * other UI-thread queues are outside support. Physical origin is not authenticated: synthetic
371 * queued records can be observed without a compatibility guarantee.
372 */
373 enum class WheelBackend : std::uint8_t
374 {
375 // Value 0 is reserved. Runtime rejects a forged 0 as unknown.
376 /// A thread-scoped WH_GETMESSAGE hook compiled into this image (the single-DLL local default).
377 MessageHook = 1,
378 /// A loader-provided resident host driven through the wheel_host.h C ABI (the split topology).
379 ExternalHost = 2,
380 };
381
382 /**
383 * @enum WheelSourceHealth
384 * @brief Typed health of the selected wheel route.
385 * @details Readiness derives from the live target thread, never from a sticky installed flag. Every
386 * non-Ready state leaves wheel counting and consume disabled.
387 */
388 enum class WheelSourceHealth : std::uint8_t
389 {
390 /// No engine runs, no wheel binding exists, or the backend reported no state yet.
391 Inactive,
392 /// No target UI thread is selected. Automatic discovery retries each poll cycle.
393 TargetWait,
394 /// The wheel hook is mounted and its target thread is alive.
395 Ready,
396 /// The route was lost or disabled (target exit, failed mount, or a failed drain). Remount retries.
397 Retryable,
398 /// Old-hook removal failed on a live thread. New mounts are blocked until that thread exits.
399 CleanupBlocked,
400 };
401
402 /**
403 * @struct Settings
404 * @brief Poll-thread and gamepad tuning applied when start() builds the engine.
405 * @details The gamepad knobs take effect only at start(); change require_focus live with set_require_focus.
406 */
407 struct Settings
408 {
409 /// Time between poll cycles. Clamped to the MIN/MAX poll-interval bounds.
410 std::chrono::milliseconds poll_interval = DEFAULT_POLL_INTERVAL;
411 /// When true (default), key events are ignored unless this process owns the foreground window.
412 bool require_focus = true;
413 /// XInput controller index (0-3) polled for gamepad bindings. Clamped to range.
414 int gamepad_index = 0;
415 /// Analog trigger deadzone (0-255). A trigger above this reads as pressed.
416 int trigger_threshold = GamepadCode::TriggerThreshold;
417 /// Thumbstick deadzone (0-32767). An axis exceeding this in any direction reads as pressed.
418 int stick_threshold = GamepadCode::StickThreshold;
419 /// Wheel-capture source built at start() for mouse-wheel bindings. Default is the local MessageHook.
420 WheelBackend wheel_backend = WheelBackend::MessageHook;
421 /**
422 * @brief The resident host table for @ref WheelBackend::ExternalHost. Ignored for the other backend.
423 * @details The loader fills it with wheel_host_start before this generation starts. The engine does
424 * not own it. The table must outlive the engine.
425 */
426 const WheelHostTable *wheel_host = nullptr;
427 /**
428 * @brief Whether @ref WheelBackend::ExternalHost must have a valid host.
429 * @details When true, start() rejects an invalid table or failed lease, and a later runtime host
430 * failure never selects the local backend. When false, a start-time failure selects
431 * @ref WheelBackend::MessageHook.
432 */
433 bool wheel_host_required = true;
434 /**
435 * @brief Optional explicit wheel target UI thread id. Zero (default) selects automatic discovery.
436 * @details A non-zero id must belong to this process; start() rejects a foreign id. Automatic
437 * discovery resolves the route from the current foreground window when this process owns
438 * it, keeps a healthy mounted route through temporary focus loss, and migrates when
439 * foreground returns on a different thread of this process. An explicit id pins the route
440 * and never migrates.
441 */
442 std::uint32_t wheel_target_thread_id = 0;
443 };
444
445 /**
446 * @brief Returns the process-wide Input singleton.
447 * @details Never throws and never terminates. If first-use allocation fails, a complete inert singleton is
448 * published instead: registration and start() report ErrorCode::OutOfMemory, every query reads
449 * inactive, and the mutators are no-ops. No poll thread, binding storage, or partially built
450 * engine is published on that path, and the inert state latches for the process generation.
451 * @note Callback-safe after first use: only the first call can allocate, and no call throws.
452 */
453 [[nodiscard]] static Input &instance() noexcept;
454
455 /**
456 * @brief Registers one binding from a ComboBinding and returns a guard that owns its callback's lifetime.
457 * @details Materializes one entry per combo, all sharing binding.name (OR logic); see the class-level
458 * details for when a registration goes live. An empty combos list registers an inert but
459 * addressable binding (rebind can populate it later) and still returns a valid guard.
460 * @param binding The binding description (moved).
461 * @return A BindingGuard on success, ErrorCode::OutOfMemory on allocation failure, or
462 * ErrorCode::ShutdownInProgress during a callback drain. A null callback creates an inert binding
463 * that remains addressable by name. A terminal teardown veto also reports ShutdownInProgress.
464 * @note Setup/control-plane only: registration may allocate and reshapes the binding set.
465 */
466 [[nodiscard]] Result<BindingGuard> register_combo(ComboBinding binding) noexcept;
467
468 /**
469 * @brief Builds the poll engine with the given settings and starts the poll thread.
470 * @details Bindings staged before start() seed the engine. Calling start() while already running is a
471 * no-op success. A start() with nothing staged is also a no-op success: it builds no poll thread
472 * and is_running() stays false. The engine is constructed by the first start() that has at least
473 * one staged binding.
474 * @param settings Poll cadence, focus gate, gamepad tuning, and wheel backend.
475 * @return Result<void>. ErrorCode::InvalidArg reports an invalid backend or host table.
476 * ErrorCode::OutOfMemory reports allocation failure. ErrorCode::SystemCallFailed reports thread
477 * or required-host lease failure. ErrorCode::ShutdownInProgress reports teardown conflict.
478 * @note Allocation, system-call, and callback-drain failures are retryable. The staged bindings remain, so
479 * a later start() attempts the same set again. A process-lifetime veto is terminal.
480 * @note Setup/control-plane only: the start allocates the engine and creates the poll thread.
481 */
482 [[nodiscard]] Result<void> start(Settings settings) noexcept;
483
484 /// Starts the engine with default settings. See start(Settings).
485 39 [[nodiscard]] Result<void> start() noexcept { return start(Settings{}); }
486
487 /**
488 * @brief Stops the poll thread and clears all bindings on the normal path.
489 * @details The normal path joins the poll thread, removes detours, and delivers final Hold releases.
490 * That path is idempotent, and the facade can start again.
491 * @note DLL_PROCESS_DETACH callers retain the owner before the first wait. A veto takes no mutex and
492 * destroys no staged callable. It stops a running poll loop by detach, never a join, except at
493 * process exit. It retains the facade owner, module references, and detours. A failed join retains
494 * the same owner set.
495 * @note Callable from a binding callback. Such a call is asynchronous: is_running() reads false, callbacks
496 * already staged for the current cycle still complete, and the join, detour removal, and final
497 * on_state_change(false) run on a background retirement thread. If that thread cannot take the
498 * retirement, the whole owner is retained for the process lifetime and no final
499 * on_state_change(false) is delivered.
500 * @note Setup/control-plane only: the normal path joins the poll thread. The binding-callback call above is
501 * the documented asynchronous exception.
502 */
503 void shutdown() noexcept;
504
505 /// Returns true while the poll thread is running.
506 [[nodiscard]] bool is_running() const noexcept;
507
508 /// Returns the number of registered binding entries (pending before start, or live after).
509 [[nodiscard]] std::size_t binding_count() const noexcept;
510
511 /**
512 * @brief Queries whether any combo of a named binding is currently pressed.
513 * @param name The binding name.
514 * @return true if active; false if the engine is not running or the name is unknown.
515 * @note Callback-safe and thread-safe. Each call pays a reference-count acquire on the live poller plus a
516 * name hash, and that acquire is not lock-free on the shipped toolchains; for a per-frame query
517 * resolve a BindingToken once and use is_active(token).
518 */
519 [[nodiscard]] bool is_active(std::string_view name) const noexcept;
520
521 /**
522 * @brief Resolves a binding name to a generation-checked token for repeated low-overhead queries.
523 * @param name The binding name.
524 * @return A valid token when running and the name is registered; an invalid token otherwise.
525 * @note Setup/control-plane only: acquire once (or after a reshape), then query with is_active(token).
526 */
527 [[nodiscard]] BindingToken acquire_token(std::string_view name) const noexcept;
528
529 /**
530 * @brief Queries a binding through a previously acquired token (the per-frame hot path).
531 * @param token A token from acquire_token.
532 * @return true if the token's binding is currently pressed; false if inactive, stale, invalid, or not
533 * running.
534 * @note Callback-safe and allocation-free. The token removes the name-hash cost, not the poller acquire.
535 */
536 [[nodiscard]] bool is_active(const BindingToken &token) const noexcept;
537
538 /**
539 * @brief Reports whether a token still matches the live binding generation.
540 * @param token A token from acquire_token.
541 * @return true when the token is valid and current; false otherwise (re-acquire to recover).
542 * @note Callback-safe: the same poller-snapshot cost as is_active(token).
543 */
544 [[nodiscard]] bool token_current(const BindingToken &token) const noexcept;
545
546 /**
547 * @brief Replaces the trigger combos of every binding sharing @p name (the INI hot-reload rebind path).
548 * @details Matching combo counts rewrite in place, carrying each entry's held state across the key swap;
549 * differing counts rebuild the entry set carrying callback identity, mode, and name forward and
550 * synthesize on_state_change(false) for any dropped held binding after the rebuild. An empty list
551 * unbinds while keeping a single inert sentinel so the name stays addressable.
552 * @param name Binding name previously registered.
553 * @param combos Replacement combos (may be empty to unbind).
554 * @return Success for a registered name. Returns ErrorCode::InvalidArg for an unknown name. Returns
555 * ErrorCode::OutOfMemory after allocation failure, with all prior binding state unchanged.
556 * @note Thread-safe; safe to call while the poll thread is running. A press or held(true) callback staged
557 * from the prior combo cannot fire after this returns; a staged release(false) is still delivered so
558 * a binding held as the swap lands ends released, not stranded. A callback that already began may
559 * finish before an external call returns; a rebind reached from an input callback retires the old
560 * generation without waiting on the callback stack that requested it.
561 * @note Setup/control-plane only: the rebind can allocate and reshapes the binding set.
562 */
563 [[nodiscard]] Result<void> rebind(std::string_view name, KeyComboList combos) noexcept;
564
565 /**
566 * @brief Enables or disables passthrough suppression for every binding sharing @p name.
567 * @details Forwards to the live engine or updates pending bindings before start(). A no-op if the name is
568 * unknown. See ComboBinding::consume for which inputs can actually be masked.
569 * @param name Binding name previously registered.
570 * @param consume true to hide the binding's trigger from the game.
571 * @note Setup/control-plane only: the toggle updates the live or pending binding set.
572 */
573 void set_consume(std::string_view name, bool consume) noexcept;
574
575 /**
576 * @brief Reports occupancy of the bounded same-frame gamepad-chord suppression table.
577 * @details A non-zero @ref ConsumeCapacity::rejected reports that the table bound left some eligible
578 * consume chords on the reactive mask alone. Every field reads zero whenever no engine is live.
579 * @return The live occupancy.
580 * @note Callback-safe and allocation-free, but not lock-free. The facade query takes the bounded
581 * atomic<shared_ptr> poller snapshot. The occupancy itself is one relaxed atomic load.
582 */
583 [[nodiscard]] ConsumeCapacity consume_capacity() const noexcept;
584
585 /**
586 * @brief Reports the typed health of the selected wheel route.
587 * @details A backend error (host publish, drain, health, or retarget failure) is latched and logged, so
588 * a failed route reads as a non-Ready state here instead of silent zero input.
589 * @return The current wheel-source health.
590 * @note Setup/control-plane only: the local backend query rechecks target-thread liveness under the
591 * interception lock.
592 */
593 [[nodiscard]] WheelSourceHealth wheel_source_health() const noexcept;
594
595 /**
596 * @brief Sets whether the engine requires foreground focus before processing key events.
597 * @param require_focus true to gate on foreground (default), false to process regardless of focus.
598 * @note Thread-safe; takes effect immediately, before or after start().
599 * @note Setup/control-plane only: a configuration toggle, not a per-frame call.
600 */
601 void set_require_focus(bool require_focus) noexcept;
602
603 /**
604 * @brief Removes every binding sharing @p name (a name maps to many combos).
605 * @details Forwards to the live engine, or erases matching entries from the pending set before start(). A
606 * staged callback for a removed entry cannot begin after this returns. A call reached from an
607 * input callback does not wait on input callbacks already in flight.
608 * @param name Binding name to remove.
609 * @param invoke_callbacks When true (default) an active hold receives on_state_change(false) before
610 * erasure. The loader-lock-safe Logic-DLL unload path passes false because the
611 * hosting DLL's callback pages may be unmapping.
612 * @return Number of bindings removed. Zero also reports allocation refusal.
613 * The refusal preserves state and logs its cause.
614 * @note Setup/control-plane only: the removal reshapes the binding set and can run callbacks.
615 */
616 std::size_t remove_bindings_by_name(std::string_view name, bool invoke_callbacks = true) noexcept;
617
618 /**
619 * @brief Drops every binding without stopping the poll thread.
620 * @details Forwards to the live engine and clears the pending set. The poll thread keeps running and can be
621 * reseeded. A staged callback for a cleared entry cannot begin after this returns. A call reached
622 * from an input callback does not wait on input callbacks already in flight.
623 * @param invoke_callbacks When true (default) active holds receive on_state_change(false) before erasure;
624 * the loader-lock-safe unload path passes false.
625 * @note Setup/control-plane only: the clear drops every binding and can run callbacks.
626 */
627 void clear_bindings(bool invoke_callbacks = true) noexcept;
628
629 /**
630 * @brief Retires the named bindings and waits for all staged input callable storage to be destroyed.
631 * @param binding_names Binding names owned by the Logic DLL being prepared for unload.
632 * @param timeout Maximum time to wait after closing callback-staging admission.
633 * @return Only CallbackDrainStatus::Drained satisfies the input precondition for unmapping the callback
634 * provider. Retirement destroys the callback through the binding's delivery gate, so an
635 * outstanding BindingGuard does not keep one alive; a binding still held here receives its
636 * balancing on_state_change(false) during the drain rather than at that guard's release.
637 * @note Setup/control-plane only. Must run off the Windows loader lock and outside input callbacks.
638 * @note Callback staging remains closed after return. Call start() only after the containing unload
639 * transaction has also drained its other callback sources.
640 */
641 [[nodiscard]] CallbackDrainStatus prepare_logic_dll_unload(
642 std::span<const std::string_view> binding_names,
643 std::chrono::milliseconds timeout
644 ) noexcept;
645
646 /**
647 * @brief Retires every binding and waits for all staged input callable storage to be destroyed.
648 * @param timeout Maximum time to wait after closing callback-staging admission.
649 * @return Only CallbackDrainStatus::Drained satisfies the input precondition for unmapping callback
650 * providers. Retirement reaches callbacks through their delivery gates, as
651 * prepare_logic_dll_unload documents.
652 * @note Setup/control-plane only. Must run off the Windows loader lock and outside input callbacks.
653 * @note Callback staging remains closed after return. A later start() re-arms it only after a successful
654 * drain.
655 */
656 [[nodiscard]] CallbackDrainStatus prepare_logic_dll_unload_all(std::chrono::milliseconds timeout) noexcept;
657
658 private:
659 // Unconditional friend: test access lives outside this installed definition, so its tokens never vary
660 // with a build macro.
661 friend struct detail::InputTestSeams;
662
663 Input() noexcept;
664 ~Input() noexcept;
665
666 Input(const Input &) = delete;
667 Input &operator=(const Input &) = delete;
668 Input(Input &&) = delete;
669 Input &operator=(Input &&) = delete;
670
671 // This identity-keyed consume clear supports a consume binding's guard teardown. A guard owns the exact
672 // registration and must clear it even when its name is empty. The function routes to the live or pending
673 // binding set, like set_consume.
674 void set_consume_by_owner(std::uint64_t owner, bool consume) noexcept;
675
676 // Retires the delivery gates of the selected bindings before the unload drain removes them.
677 // every_binding covers the whole engine. Returns false when a gate remained active at the deadline or the
678 // gate handles were not collectable. The drain maps either result to TimedOut.
679 [[nodiscard]] bool retire_gates_for_unload(
680 std::span<const std::string_view> binding_names,
681 bool every_binding,
682 std::chrono::steady_clock::time_point deadline
683 ) noexcept;
684
685 // pimpl: owns the engine (src/internal/input_poller.hpp) and the pending-binding staging. Defined in
686 // src/input.cpp, so the only engine type this header names stays incomplete.
687 struct Impl;
688
689 // The empty deleter preserves the pointer-sized ABI and obeys the shutdown() retention latch.
690 struct ImplDeleter
691 {
692 void operator()(Impl *impl) const noexcept;
693 };
694 using ImplOwner = std::unique_ptr<Impl, ImplDeleter>;
695
696 // Allocates the Impl with a caught failure, so the noexcept constructor can publish the inert state.
697 [[nodiscard]] static ImplOwner create_impl() noexcept;
698
699 // True after first-use allocation failure or a process-lifetime vetoed retention. See instance().
700 [[nodiscard]] bool is_inert() const noexcept;
701
702 // Shared snapshot for the callback-safe queries; null when inert or not running.
703 [[nodiscard]] std::shared_ptr<detail::InputPoller> poller_snapshot() const noexcept;
704
705 ImplOwner m_impl;
706 };
707
708 /**
709 * @brief Free-function form of Input::instance().register_combo, so a consumer writes input::register_combo.
710 * @param binding The binding description (moved).
711 * @return A BindingGuard on success, or an ErrorCode-bearing failure (see Input::register_combo).
712 * @note Setup/control-plane only (see Input::register_combo).
713 */
714 [[nodiscard]] Result<BindingGuard> register_combo(ComboBinding binding) noexcept;
715
716 /**
717 * @brief Returns the process-default Scope, so a consumer can write input::scope().add(...).
718 * @details The process-default Scope has process lifetime under `[B-47]`. Its destructor never runs.
719 * clear() releases parked guards in reverse insertion order.
720 * @note During ordinary unload, call scope().clear() off the loader lock. Otherwise, parked guards and
721 * callbacks remain until process exit.
722 */
723 [[nodiscard]] Scope &scope() noexcept;
724 } // namespace input
725 } // namespace DetourModKit
726
727 #endif // DETOURMODKIT_INPUT_HPP
728