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 | 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 | 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 |