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 |