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