./
Coverage:
low: ≥ 0%
medium: ≥ 75.0%
high: ≥ 90.0%
Lines:
14 / 14
100.0%
Branches:
4 / 5
80.0%
Functions:
8 / 8
100.0%

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: combo bindings, edge detection, and opt-in passthrough suppression.
7 * @warning `[B-100]` Run registration and Input::start() outside the loader lock. Registration allocates, and start()
8 * creates the poll thread. The loader-lock shutdown path vetoes the join and detaches the thread.
9 */
10
11 #include "DetourModKit/error.hpp"
12 #include "DetourModKit/input_codes.hpp"
13 // A full include gives ExternalHost consumers the complete WheelHostTable. The C header excludes <windows.h>.
14 #include "DetourModKit/abi/wheel_host.h"
15
16 #include <chrono>
17 #include <cstddef>
18 #include <cstdint>
19 #include <functional>
20 #include <memory>
21 #include <span>
22 #include <string>
23 #include <string_view>
24 #include <vector>
25
26 namespace DetourModKit
27 {
28 namespace detail
29 {
30 // The poll engine (src/internal/input_poller.hpp).
31 class InputPoller;
32
33 // Test-only accessor (src/internal/input_test_seams.hpp), so this header has no macro-dependent member.
34 struct InputTestSeams;
35 } // namespace detail
36
37 namespace input
38 {
39 /**
40 * @brief One alternative key combination: OR across keys, AND across modifiers. Keep all codes of one combo in
41 * one device group (keyboard/mouse or gamepad). Mouse-wheel codes are a standalone, trigger-only source.
42 */
43 struct KeyCombo
44 {
45 std::vector<InputCode> keys;
46 std::vector<InputCode> modifiers;
47 };
48
49 /// Alternative key combinations with OR between combos. An empty list binds no keys.
50 using KeyComboList = std::vector<KeyCombo>;
51
52 /** @brief Edge model for a binding. */
53 enum class Trigger : std::uint8_t
54 {
55 /// Fires the on_press callback once per key-down edge.
56 Press,
57 /**
58 * @brief Sends on_state_change(true) on press and false on release. A teardown that invokes callbacks
59 * sends one false while held.
60 */
61 Hold
62 };
63
64 /** @brief Typed outcome of an off-loader-lock input callback drain. */
65 enum class CallbackDrainStatus : std::uint8_t
66 {
67 /// Every selected binding was retired, its callback destroyed, and all staged callable storage destroyed.
68 Drained,
69 /// A callback or staged callable storage outlived the deadline, or the drain failed to collect its gates.
70 TimedOut,
71 /// The caller runs inside an input callback and cannot wait for its own storage lease.
72 SelfDelivery,
73 /// Another control thread owns the drain transaction.
74 InProgress,
75 /// At least one selected binding was not retired.
76 RetireFailed
77 };
78
79 /** @brief Returns "Press" or "Hold", or "Unknown" for an out-of-range value. */
80 470 [[nodiscard]] constexpr std::string_view to_string(Trigger trigger) noexcept
81 {
82
3/3
✓ Branch 2 → 3 taken 410 times.
✓ Branch 2 → 4 taken 59 times.
✓ Branch 2 → 5 taken 1 time.
470 switch (trigger)
83 {
84 410 case Trigger::Press:
85 410 return "Press";
86 59 case Trigger::Hold:
87 59 return "Hold";
88 }
89 1 return "Unknown";
90 }
91
92 /// Default poll interval, about 60 Hz.
93 inline constexpr std::chrono::milliseconds DEFAULT_POLL_INTERVAL{16};
94 /// Lower clamp for the poll interval.
95 inline constexpr std::chrono::milliseconds MIN_POLL_INTERVAL{1};
96 /// Upper clamp for the poll interval.
97 inline constexpr std::chrono::milliseconds MAX_POLL_INTERVAL{1000};
98
99 /**
100 * @brief Describes one named input binding for register_combo.
101 * @details Modifier matching is strict across the whole binding set. A key that any binding uses as a modifier
102 * blocks each binding that does not list it, so "V" cannot fire while "Shift+V" is pressed.
103 * @warning Callbacks must return quickly. They run on the poll thread, but a balancing edge can run on the
104 * thread that ends the hold. Captured objects must stay alive until the BindingGuard is released or
105 * the Input facade shuts down.
106 */
107 struct ComboBinding
108 {
109 /** @brief The key of the name-based calls. An empty name is addressable only through the guard. */
110 std::string name = {};
111
112 /// Press or Hold edge model.
113 Trigger trigger = Trigger::Press;
114
115 /// OR alternatives. An empty list registers an inert, addressable binding that rebind can populate.
116 KeyComboList combos = {};
117
118 /**
119 * @brief Hides consumed digital gamepad triggers and wheel events while the guard remains held.
120 * @details The poll cycle masks active gamepad triggers. Same-frame chord rules remove that delay
121 * within Input::consume_capacity. Compatible DMK copies share unsuppressed poll samples.
122 * A failed paired XInput route check stops suppression on both exports.
123 * @note Best-effort. Suppression can lapse without notice. A route probe proves its own call.
124 * Foreign handlers can bypass other callers or rewrite controller state. Later hooks can restore
125 * wheel events. A binding must tolerate game access to its trigger.
126 */
127 bool consume = false;
128
129 /// Invoked on the key-down edge when trigger == Press. Empty for a Hold binding.
130 std::function<void()> on_press = {};
131
132 /// Invoked with true when held and false when released, if trigger == Hold. Empty for a Press binding.
133 std::function<void(bool)> on_state_change = {};
134 };
135
136 /** @brief Occupancy of the same-frame gamepad-chord table, in chord shapes. Registration never fails on it. */
137 struct ConsumeCapacity
138 {
139 /// Distinct chord shapes the live table can hold, or zero while no engine runs.
140 std::size_t capacity{0};
141 /**
142 * @brief Shapes published to the hook, or zero while a registered modifier is not a digital gamepad button.
143 * @details In that case no chord keeps same-frame suppression, and @ref rejected does not count the loss.
144 */
145 std::size_t active{0};
146 /// Eligible shapes the bound turned away. Non-zero means some chords lost same-frame suppression.
147 std::size_t rejected{0};
148 };
149
150 /**
151 * @brief Generation-checked handle to a named binding's entries for low-overhead repeated queries.
152 * @details These reshapes advance the generation: register, name-based rebind or removal, clear, a real
153 * consume-flag transition, and a guard release that clears a set consume flag. A consume set that
154 * repeats the current value keeps every token current. No token matches another engine after a
155 * shutdown and start. Default, unknown-name, and allocation-failed tokens are invalid.
156 */
157 class BindingToken
158 {
159 public:
160 9 BindingToken() = default;
161
162 /// True if acquire_token resolved the name. A valid token can still be stale (see Input::token_current).
163 142 [[nodiscard]] bool valid() const noexcept { return m_generation != 0; }
164
165 private:
166 friend class DetourModKit::detail::InputPoller;
167
168 // 0 marks an unresolved token. The process-wide counter starts at 1.
169 std::uint64_t m_generation{0};
170
171 // Read only while m_generation matches the engine, which keeps each index in bounds.
172 std::vector<std::size_t> m_indices;
173 };
174
175 /**
176 * @brief RAII cancellation token of a binding from register_combo, config::press_combo, or config::hold_combo.
177 * @details Release or destruction disables the callback and keeps the binding registered. Removal is by name
178 * through @ref Input::remove_bindings_by_name or for every binding through @ref Input::clear_bindings.
179 * After a release outside a callback returns, no callback of this binding runs or can start. No other
180 * thread is still inside its teardown code or a retired callable's capture destructors, so the caller
181 * can destroy captured state. A release inside a callback does not wait, so work on another thread
182 * can still run after it returns. Its balancing edge runs inline or at the unwind of the in-flight
183 * delivery.
184 *
185 * A Hold guard synthesizes one on_state_change(false) if true was the last edge forwarded, and never
186 * re-enters a callback on the stack. Every release clears the consume flag of its registration,
187 * whether the registration or a later set_consume set it. Release and the prepare_logic_dll_unload
188 * retirement exclude each other, so the promise above holds for both. An ordinary release does not
189 * destroy a callable that the engine still holds. A guard that outlives the retirement stays valid but
190 * no longer reaches the callback.
191 * @note Setup/control-plane only: destroy a guard outside hooks and input callbacks.
192 * @warning release can wait, with no bound, for an in-flight callback of this binding or for a concurrent
193 * prepare_logic_dll_unload. The deadlock escape inside a callback is per-thread. Never destroy a guard
194 * while the thread owns a lock or join that callbacks or capture destructors can wait on.
195 */
196 class BindingGuard
197 {
198 public:
199 // Out of line: an inline default constructor instantiates ~unique_ptr<Impl> on the incomplete Impl.
200 BindingGuard() noexcept;
201 ~BindingGuard() noexcept;
202
203 BindingGuard(BindingGuard &&other) noexcept;
204 BindingGuard &operator=(BindingGuard &&other) noexcept;
205 BindingGuard(const BindingGuard &) = delete;
206 BindingGuard &operator=(const BindingGuard &) = delete;
207
208 /// Disables the binding's callback, then runs the binding teardown action once. Idempotent.
209 void release() noexcept;
210
211 /// True while the callback is enabled. False for a default, released, moved-from, or retired guard.
212 [[nodiscard]] bool is_active() const noexcept;
213
214 /// Returns the binding name this guard gates, or an empty view for a default or moved-from guard.
215 [[nodiscard]] std::string_view name() const noexcept;
216
217 private:
218 friend class Input;
219 // Defined in src/input.cpp.
220 struct Impl;
221 explicit BindingGuard(std::unique_ptr<Impl> impl) noexcept;
222 std::unique_ptr<Impl> m_impl;
223 };
224
225 /**
226 * @brief Owns BindingGuards and releases them in reverse insertion order on clear or destruction.
227 * @note Setup/control-plane only: destroy a Scope where a BindingGuard can be destroyed. Release can block.
228 */
229 class Scope
230 {
231 public:
232 1645 Scope() = default;
233 4917 ~Scope() noexcept { clear(); }
234
235 3274 Scope(Scope &&) noexcept = default;
236 Scope &operator=(Scope &&) noexcept;
237 Scope(const Scope &) = delete;
238 Scope &operator=(const Scope &) = delete;
239
240 /// Takes ownership of any guard, even an inert one.
241 void add(BindingGuard guard);
242
243 /**
244 * @brief Releases the current guards in reverse insertion order. Idempotent.
245 * @details A reentrant add stays in this Scope for the next clear.
246 * @note Setup/control-plane only: the release runs consumer callbacks on the caller's thread.
247 */
248 void clear() noexcept;
249
250 /**
251 * @brief Abandons every guard with no release, destruction, gate mutex, or balancing edge. Idempotent.
252 * @details Call it only for process termination (see Session::abandon). Guard release and capture
253 * destructors are unsafe under the loader lock. Use clear() for a live teardown.
254 * @note Setup/control-plane only: a process-teardown path.
255 */
256 void abandon() noexcept;
257
258 /// Number of guards currently owned.
259
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; }
260
261 private:
262 // Allocated at the first add, so abandon() retains it with no allocation or destruction.
263 std::unique_ptr<std::vector<BindingGuard>> m_guards;
264 };
265
266 /**
267 * @brief Process singleton that owns the poll thread, the binding set, and the interception layer.
268 * @details A binding registered while the engine runs fires from the next cycle. A binding registered before
269 * the engine exists stays pending. Each linked DMK instance has one interception layer with one owner.
270 * @note MessageHook keeps this module mapped after its first successful publication.
271 * @note ExternalHost keeps the wheel hook and its module reference in the loader module.
272 */
273 class Input
274 {
275 public:
276 /**
277 * @brief Selects the source that captures mouse-wheel notches.
278 * @details Both backends see WM_MOUSEWHEEL and WM_MOUSEHWHEEL records removed from one selected UI-thread
279 * queue. Direct sent delivery, later DefWindowProc parent delivery, raw-input-only paths, and
280 * other UI-thread queues are outside support. Physical origin is not authenticated, so synthetic
281 * queued records can count, with no compatibility guarantee.
282 */
283 enum class WheelBackend : std::uint8_t
284 {
285 // Value 0 is reserved. A start() that builds the engine rejects it with ErrorCode::InvalidArg.
286 /// A thread-scoped WH_GETMESSAGE hook compiled into this image (the single-DLL local default).
287 MessageHook = 1,
288 /// A loader-provided resident host driven through the wheel_host.h C ABI (the split topology).
289 ExternalHost = 2,
290 };
291
292 /** @brief Typed health of the selected wheel route. Only Ready enables wheel counting and suppression. */
293 enum class WheelSourceHealth : std::uint8_t
294 {
295 /// No engine runs, no wheel binding exists, or the ExternalHost lease is not open.
296 Inactive,
297 /// No target UI thread is selected. Automatic discovery retries each poll cycle.
298 TargetWait,
299 /// The wheel hook is mounted and its target thread is alive.
300 Ready,
301 /// The route was lost or disabled (target exit, failed mount, or a failed drain). Remount retries.
302 Retryable,
303 /// Old-hook removal failed on a live thread. No new mount starts until that thread exits.
304 CleanupBlocked,
305 };
306
307 /** @brief Engine tuning for start(). A set_require_focus call overrides require_focus at any time. */
308 struct Settings
309 {
310 /// Time between poll cycles. Clamped to the MIN/MAX poll-interval bounds.
311 std::chrono::milliseconds poll_interval = DEFAULT_POLL_INTERVAL;
312 /// If true (default), the engine ignores key events unless this process owns the foreground window.
313 bool require_focus = true;
314 /// XInput controller index (0 to 3) polled for gamepad bindings. Clamped to range.
315 int gamepad_index = 0;
316 /// Analog trigger deadzone (0 to 255). A trigger above this reads as pressed.
317 int trigger_threshold = GamepadCode::TriggerThreshold;
318 /// Thumbstick deadzone (0 to 32767). An axis past this value in any direction reads as pressed.
319 int stick_threshold = GamepadCode::StickThreshold;
320 /// Wheel-capture source that start() builds for mouse-wheel bindings.
321 WheelBackend wheel_backend = WheelBackend::MessageHook;
322 /**
323 * @brief ExternalHost table that wheel_host_start fills before start(). It must outlive the engine.
324 * MessageHook ignores it.
325 */
326 const WheelHostTable *wheel_host = nullptr;
327 /**
328 * @brief Whether @ref WheelBackend::ExternalHost must have a valid host.
329 * @details If true, start() rejects an invalid table or failed lease, and a later host failure never
330 * selects the local backend. If false, a start-time failure selects MessageHook.
331 */
332 bool wheel_host_required = true;
333 /**
334 * @brief Wheel target UI thread id. A non-zero id pins the route. Zero (default) selects discovery.
335 * @details start() rejects an id that is not a live thread of this process. Discovery follows the
336 * foreground window of this process across its threads and keeps a healthy route through
337 * focus loss.
338 */
339 std::uint32_t wheel_target_thread_id = 0;
340 };
341
342 /**
343 * @brief Returns the process-wide Input singleton.
344 * @details If first-use allocation fails, it returns an inert singleton that stays inert. Registration and
345 * start() then report ErrorCode::OutOfMemory, queries read inactive, and mutators do nothing.
346 * @note Callback-safe after first use: only the first call can allocate.
347 */
348 [[nodiscard]] static Input &instance() noexcept;
349
350 /**
351 * @brief Registers @p binding and returns the guard that owns its callback lifetime.
352 * @details A null callback or an empty combo list still registers an addressable binding. A call concurrent
353 * with shutdown() can register nothing and return a guard whose is_active() is false.
354 * @return ErrorCode::OutOfMemory on allocation failure. ErrorCode::ShutdownInProgress after a
355 * process-lifetime veto or while callback staging is closed (see prepare_logic_dll_unload()).
356 * @note Setup/control-plane only: registration can allocate and reshapes the binding set.
357 */
358 [[nodiscard]] Result<BindingGuard> register_combo(ComboBinding binding) noexcept;
359
360 /**
361 * @brief Builds the engine from the pending bindings and starts the poll thread.
362 * @details While a callback drain is active or pending, start() returns ShutdownInProgress. Otherwise a
363 * start() while the engine runs only re-arms callback staging and succeeds. With no pending
364 * binding, start() also succeeds, builds no poll thread, and is_running() stays false.
365 * @return ErrorCode::InvalidArg reports an invalid backend, host table, or wheel target thread. OutOfMemory
366 * reports an allocation failure. ShutdownInProgress reports a teardown conflict. SystemCallFailed
367 * reports a module-reference, thread, or required-host lease failure. Its Error::detail is
368 * GetLastError(), the std::system_error code value, or the magnitude of the negative
369 * DMK_WHEELHOST_* status, in that order.
370 * @note Allocation, system-call, and callback-drain failures are retryable. The pending bindings and a
371 * pending set_require_focus value remain for a later start(). A process-lifetime veto is terminal.
372 * @note Setup/control-plane only: start() allocates the engine and creates the poll thread.
373 */
374 [[nodiscard]] Result<void> start(Settings settings) noexcept;
375
376 /// Starts the engine with default settings. See start(Settings).
377 42 [[nodiscard]] Result<void> start() noexcept { return start(Settings{}); }
378
379 /**
380 * @brief On the normal path, joins the poll thread, removes the detours, delivers final Hold releases, and
381 * clears all bindings.
382 * @details Idempotent. Except after the process-lifetime veto below, the facade can start again.
383 * @note A call under the loader lock, from DLL_PROCESS_DETACH, or at process exit is a process-lifetime
384 * veto. It retains the facade owner, the engine, module references, and detours. It takes no mutex
385 * and destroys no staged callable. Outside process exit, it stops the poll loop and detaches the poll
386 * thread. A failed join also retains the engine, module references, and detours.
387 * @note A call from a binding callback on any thread is asynchronous. That covers a balancing edge that
388 * rebind, removal, clear, or a guard release delivers on its caller's thread. The call returns with
389 * is_running() false, and callbacks already staged for the current cycle still complete. An
390 * off-thread reaper runs the join, detour removal, and final on_state_change(false). If the hand-off
391 * to the reaper fails, the whole owner stays retained for the process with no final release.
392 * @note Setup/control-plane only, except for the asynchronous binding-callback call.
393 */
394 void shutdown() noexcept;
395
396 /// Returns true while the poll thread runs.
397 [[nodiscard]] bool is_running() const noexcept;
398
399 /**
400 * @brief Returns the number of registered binding entries (pending before start, or live after).
401 * @details Each combo is one entry, and an empty combo list is one inert entry.
402 */
403 [[nodiscard]] std::size_t binding_count() const noexcept;
404
405 /**
406 * @brief Reports whether any combo of @p name is pressed. False if no engine runs or the name is unknown.
407 * @note Callback-safe and thread-safe, but not lock-free. For a per-frame query, use a BindingToken.
408 */
409 [[nodiscard]] bool is_active(std::string_view name) const noexcept;
410
411 /**
412 * @brief Resolves @p name to a token, which is invalid unless the engine runs and the name is registered.
413 * @note Setup/control-plane only: acquire once and after each reshape. Query with is_active(token).
414 */
415 [[nodiscard]] BindingToken acquire_token(std::string_view name) const noexcept;
416
417 /**
418 * @brief Per-frame query: true while the token's binding is pressed. A stale or invalid token reads false.
419 * @note Callback-safe and allocation-free. The token removes the name hash, not the poller snapshot.
420 */
421 [[nodiscard]] bool is_active(const BindingToken &token) const noexcept;
422
423 /**
424 * @brief True if @p token is valid and matches the live generation. If false, acquire a new token.
425 * @note Callback-safe: the same poller-snapshot cost as is_active(token).
426 */
427 [[nodiscard]] bool token_current(const BindingToken &token) const noexcept;
428
429 /**
430 * @brief Replaces the combos of each binding that shares @p name. An empty list unbinds and keeps the name.
431 * @details An equal combo count rewrites in place and keeps held state. Another count rebuilds every entry
432 * from the first binding of that name, then sends one on_state_change(false) to each held binding.
433 * @return ErrorCode::InvalidArg for an unknown name. OutOfMemory leaves all binding state unchanged.
434 * @note Thread-safe, also while the poll thread runs. After return, a press or true edge staged from the
435 * prior combos cannot fire, but a staged false edge is still delivered. A call outside an input
436 * callback waits for old-generation callbacks that already began. A call from an input callback
437 * disables the old generation and does not wait.
438 * @note Setup/control-plane only: the rebind can allocate and reshapes the binding set.
439 */
440 [[nodiscard]] Result<void> rebind(std::string_view name, KeyComboList combos) noexcept;
441
442 /**
443 * @brief Sets ComboBinding::consume on every binding that shares @p name. An unknown name is a no-op.
444 * @note Setup/control-plane only: the toggle updates the live or pending binding set.
445 */
446 void set_consume(std::string_view name, bool consume) noexcept;
447
448 /**
449 * @brief Reports occupancy of the same-frame gamepad-chord table. Each field is zero with no live engine.
450 * @note Callback-safe and allocation-free, but not lock-free: it takes the bounded poller snapshot.
451 */
452 [[nodiscard]] ConsumeCapacity consume_capacity() const noexcept;
453
454 /**
455 * @brief Reports the typed health of the selected wheel route.
456 * @details The engine logs a host publish, drain, health, or retarget error. A failed route reads as a
457 * non-Ready state.
458 * @note Setup/control-plane only: the local backend checks the target thread under the interception lock.
459 */
460 [[nodiscard]] WheelSourceHealth wheel_source_health() const noexcept;
461
462 /**
463 * @brief Sets whether the engine processes key events only while this process owns the foreground window.
464 * @details A live engine applies the value at once. With no engine, it overrides Settings::require_focus
465 * for the next start() that builds one. shutdown() discards a pending value.
466 * @note Setup/control-plane only: a thread-safe configuration toggle, not a per-frame call.
467 */
468 void set_require_focus(bool require_focus) noexcept;
469
470 /**
471 * @brief Removes every live or pending binding that shares @p name.
472 * @details A staged callback of a removed entry cannot begin after this returns. A call from an input
473 * callback does not wait on input callbacks in flight. If @p invoke_callbacks is true, an active
474 * hold receives on_state_change(false) after its removal. A call from an input callback can defer
475 * that edge to the unwind of the in-flight delivery. If @p invoke_callbacks is false, no edge runs
476 * and the call does not wait on callbacks in flight. Because the callback DLL can unload after its
477 * drain, prepare_logic_dll_unload() passes false.
478 * @return The number of removed entries. Zero also reports a logged allocation refusal that keeps state.
479 * @note Setup/control-plane only: the removal reshapes the binding set and can run callbacks.
480 */
481 std::size_t remove_bindings_by_name(std::string_view name, bool invoke_callbacks = true) noexcept;
482
483 /**
484 * @brief Drops every live and pending binding. The poll thread stays alive and accepts new bindings.
485 * @details The callback and @p invoke_callbacks rules of remove_bindings_by_name() apply.
486 * @note Setup/control-plane only: the clear drops every binding and can run callbacks.
487 */
488 void clear_bindings(bool invoke_callbacks = true) noexcept;
489
490 /**
491 * @brief Retires the named bindings and waits until all staged input callable storage is destroyed.
492 * @details Retirement destroys each callback through its delivery gate, even while its BindingGuard lives.
493 * A held binding receives its balancing on_state_change(false) during the drain.
494 * @param timeout Maximum wait after callback-staging admission closes.
495 * @return Only CallbackDrainStatus::Drained meets the input precondition to unmap the callback provider.
496 * @note Setup/control-plane only. Must run off the Windows loader lock and outside input callbacks.
497 * @note Callback staging stays closed after return. Call start() only after the unload transaction also
498 * drained its other callback sources.
499 */
500 [[nodiscard]] CallbackDrainStatus prepare_logic_dll_unload(
501 std::span<const std::string_view> binding_names,
502 std::chrono::milliseconds timeout
503 ) noexcept;
504
505 /**
506 * @brief Retires every binding and waits until all staged input callable storage is destroyed.
507 * @details @p timeout, the retirement rules, and the Drained precondition match prepare_logic_dll_unload().
508 * @note Setup/control-plane only. Must run off the Windows loader lock and outside input callbacks.
509 * @note Callback staging stays closed after return. A later start() re-arms it only after a successful
510 * drain.
511 */
512 [[nodiscard]] CallbackDrainStatus prepare_logic_dll_unload_all(std::chrono::milliseconds timeout) noexcept;
513
514 private:
515 // Test access lives outside this header (see detail::InputTestSeams).
516 friend struct detail::InputTestSeams;
517
518 Input() noexcept;
519 ~Input() noexcept;
520
521 Input(const Input &) = delete;
522 Input &operator=(const Input &) = delete;
523 Input(Input &&) = delete;
524 Input &operator=(Input &&) = delete;
525
526 // Every guard teardown runs this identity-keyed consume clear, which also reaches an empty name.
527 void set_consume_by_owner(std::uint64_t owner, bool consume) noexcept;
528
529 // False (a gate active at the deadline, or uncollectable handles) maps to CallbackDrainStatus::TimedOut.
530 [[nodiscard]] bool retire_gates_for_unload(
531 std::span<const std::string_view> binding_names,
532 bool every_binding,
533 std::chrono::steady_clock::time_point deadline
534 ) noexcept;
535
536 // Owns the engine and the pending bindings. Defined in src/input.cpp.
537 struct Impl;
538
539 // The stateless deleter keeps the owner pointer-sized and obeys the shutdown() retention latch.
540 struct ImplDeleter
541 {
542 void operator()(Impl *impl) const noexcept;
543 };
544 using ImplOwner = std::unique_ptr<Impl, ImplDeleter>;
545
546 // Allocates the Impl with a caught failure, so the noexcept constructor can publish the inert state.
547 [[nodiscard]] static ImplOwner create_impl() noexcept;
548
549 // True after first-use allocation failure or a process-lifetime veto. See instance().
550 [[nodiscard]] bool is_inert() const noexcept;
551
552 // Shared snapshot for the callback-safe queries. Null when inert or when no engine runs.
553 [[nodiscard]] std::shared_ptr<detail::InputPoller> poller_snapshot() const noexcept;
554
555 ImplOwner m_impl;
556 };
557
558 /**
559 * @brief Calls Input::instance().register_combo() and returns its result.
560 * @note Setup/control-plane only.
561 */
562 [[nodiscard]] Result<BindingGuard> register_combo(ComboBinding binding) noexcept;
563
564 /**
565 * @brief Returns the process-default Scope. Under `[B-47]`, it lives for the process and is never destroyed.
566 * @note During an ordinary unload, call scope().clear() off the loader lock. Otherwise its guards and callbacks
567 * remain until process exit.
568 */
569 [[nodiscard]] Scope &scope() noexcept;
570 } // namespace input
571 } // namespace DetourModKit
572
573 #endif // DETOURMODKIT_INPUT_HPP
574