Skip to content

Commit a79747e

Browse files
authored
feat(config): hold-combo + consume facet, atomic inferred default (#134)
1 parent 47967f8 commit a79747e

7 files changed

Lines changed: 872 additions & 45 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ include/DetourModKit/ # Public headers -- one per module
136136
async_logger.hpp # Lock-free MPMC queue logger
137137
logger.hpp # Synchronous singleton logger
138138
win_file_stream.hpp # Win32 shared-access file stream (CreateFile backend)
139-
config.hpp # INI configuration with callback setters
139+
config.hpp # INI configuration with callback setters; also the input-binding fusions (register_press_combo / register_hold_combo) that pair an INI combo item with an InputManager press/hold binding, rebind it on reload via update_binding_combos, carry an optional per-binding "<ini_key>.Consume" suppression facet, and return an InputBindingGuard -- the hold guard synthesizes one balancing on_state_change(false) on cancel so a torn-down hold cannot strand the consumer as held (the teardown gate is the internal src/config_input_fusion.hpp HoldGate)
140140
config_watcher.hpp # Filesystem watcher (ReadDirectoryChangesW) for INI hot-reload
141141
input.hpp # Input polling (keyboard/mouse/XInput) + opt-in mouse-wheel capture and gamepad passthrough suppression; generation-checked BindingToken handles let a high-frequency consumer query a binding without the per-call name hash, failing closed on any reshape
142142
input_codes.hpp # Unified InputCode type and named key tables (incl. WheelUp/Down/Left/Right)
@@ -376,7 +376,7 @@ PATH="/c/msys64/mingw64/bin:$PATH" ./build/mingw-debug/tests/DetourModKit_tests.
376376
| InputManager | `mutex` for lifecycle, `atomic<shared_ptr<InputPoller>>` for reads | `atomic<shared_ptr<InputPoller>>` acquire-load, then the poller's `shared_lock` + relaxed load (not lock-free) |
377377
| InputIntercept (internal `src/input_intercept.*`) | File-scope atomics shared between the poll thread and the game's threads (XInput callers, window message thread); owns its safetyhook InlineHooks directly (not via HookManager) because the poll thread reads the trampoline and the hook lifetime is coupled to the poll thread; consume-until-release latch and wheel-pulse state are poll-thread-private; teardown skipped under loader lock (detours left installed against the pinned module) | Lock-free atomic loads in each detour; allocation-free, non-throwing detour bodies |
378378
| Memory cache | Sharded `SRWLOCK` + epoch-based shutdown. Each shard is `alignas(64)` with its `SrwSharedMutex` and stampede `in_flight` flag stored inline (no per-shard heap allocation, so the shard array is a fixed-size `unique_ptr<CacheShard[]>` that never relocates), keeping one shard's lock word and flag off another shard's cache line. Reader liveness is tracked in cache-line-padded per-thread stripes summed at shutdown rather than one global counter, so concurrent readers do not re-serialize on a single line; the seq_cst stripe increment still Dekker-pairs with the `s_cache_initialized` load so the shutdown drain is exact | Shared reader locks per shard; one striped reader-count increment per call |
379-
| Config | `mutex` for registration; deferred setter invocation outside lock (no reentrancy guard needed -- setters may call back into Config); `reload()` re-runs the registered items against the stashed INI path using the same deferred pattern and short-circuits on FNV-1a 64 hash match of the on-disk bytes to skip no-op reloads; bytes are read once per load/reload and fed to `CSimpleIniA::LoadData`, so the cached hash and the parsed INI state are guaranteed to reflect the same file snapshot (no TOCTOU between hash and parse); `enable_auto_reload()` owns a `ConfigWatcher` behind a separate `std::mutex` so start/stop transitions do not contend with registration traffic; setters invoked by the watcher run on the watcher thread, setters invoked by the reload hotkey run on a dedicated `ReloadServicer` thread (lazily started on first `register_reload_hotkey`, torn down in `clear_registered_items()`) so the `InputManager` poll thread never blocks on INI parsing; the servicer's press-request path takes its internal `m_mutex` around the predicate store before `cv.notify_one` to close the lost-wakeup window; all setters must be reentrant and thread-safe | N/A (startup only) |
379+
| Config | `mutex` for registration; deferred setter invocation outside lock (no reentrancy guard needed -- setters may call back into Config); `reload()` re-runs the registered items against the stashed INI path using the same deferred pattern and short-circuits on FNV-1a 64 hash match of the on-disk bytes to skip no-op reloads; bytes are read once per load/reload and fed to `CSimpleIniA::LoadData`, so the cached hash and the parsed INI state are guaranteed to reflect the same file snapshot (no TOCTOU between hash and parse); `enable_auto_reload()` owns a `ConfigWatcher` behind a separate `std::mutex` so start/stop transitions do not contend with registration traffic; setters invoked by the watcher run on the watcher thread, setters invoked by the reload hotkey run on a dedicated `ReloadServicer` thread (lazily started on first `register_reload_hotkey`, torn down in `clear_registered_items()`) so the `InputManager` poll thread never blocks on INI parsing; the servicer's press-request path takes its internal `m_mutex` around the predicate store before `cv.notify_one` to close the lost-wakeup window; all setters must be reentrant and thread-safe; the hold-combo `InputBindingGuard` owns a per-binding `HoldGate` (`src/config_input_fusion.hpp`) whose `recursive_mutex` serializes the poll-thread callback wrapper against the control-plane `release()`, so a cancelled hold delivers exactly one balancing `on_state_change(false)` and never a stale `true` after it (a re-entrant self-release from inside the callback recurses without deadlock and defers its balancing edge to the wrapper's unwind) | N/A (startup only) |
380380
| ConfigWatcher | One `StoppableWorker` per instance; worker opens the parent directory with `FILE_FLAG_BACKUP_SEMANTICS` and `FILE_FLAG_OVERLAPPED`, then pumps `ReadDirectoryChangesW` via `GetOverlappedResultEx` with a 100 ms timeout so `stop_token` is observed promptly; on stop the in-flight read is cancelled and drained with a bounded, escalating wait (timed `GetOverlappedResultEx`, then directory-handle close to force the orphaned IRP to complete, then leak the heap-bundled I/O buffer if completion still cannot be confirmed) so a deleted watched directory cannot hang teardown; debounce uses `steady_clock`; filename match is case-insensitive; `start()` and `stop()` are idempotent and serialized by an internal `std::mutex`; under loader lock the destructor pins the module, requests stop on the worker, and moves `Impl` into a per-call heap cell allocated via `new (std::nothrow)` (with a `release()` fallback on OOM that leaks the raw pointer instead of running `~Impl`) so the noexcept destructor stays honest, mirroring the `Logger::shutdown_internal` discipline | 100 ms `GetOverlappedResultEx` pump; idle CPU ~0 |
381381
| EventDispatcher | `emit()` / `emit_safe()` with no user-visible mutex on the read path via `std::atomic<std::shared_ptr<const std::vector<Entry>>>` snapshot (copy-on-write publish, acquire-load on read); zero-subscriber fast path skips the snapshot load via an atomic handler counter; writers serialize on a small `std::mutex` that never touches the emit hot path; thread-local reentrancy guard rejects subscribe/unsubscribe from within handlers so the no-mutation-during-emit invariant holds; `emit()` propagates handler exceptions, `emit_safe()` catches and skips them | Atomic acquire-load of a `shared_ptr` snapshot plus linear iteration over a contiguous vector; no reader lock |
382382
| Profiler | Lock-free ring buffer via atomic `fetch_add` on write position; odd/even sequence counter per sample slot prevents torn reads during concurrent export: `record()` opens and closes the slot with unconditional `fetch_add` (never a load-then-store) so concurrent producers racing on the same slot cannot roll the counter backwards, and the cold export path is a seqlock reader (load the sequence, copy the fields into locals, re-load the sequence behind an acquire fence, and drop the sample if it changed or is odd); `DMK_PROFILE_SCOPE(name)` requires `name` to be a string literal, enforced at compile time by a `ScopedProfile` constructor that only binds to `const char (&)[N]` | Single atomic increment + sequence-guarded field writes per sample |

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ DetourModKit is a full-featured C++23 toolkit designed to simplify common tasks
8181
- Comma-separated independent combos (e.g., `F3,Gamepad_LT+Gamepad_B`)
8282
- Named keys (`Ctrl`, `F3`, `Mouse1`, `Gamepad_A`), hex VK codes (`0x72`), and mixed formats
8383
- Opt-out sentinels: an empty value or the literal `NONE` (case-insensitive, whole-string only) leaves the binding unbound silently. A non-empty value whose every token fails to parse is logged at WARNING level naming the binding and the offending raw string.
84-
- Convenience helpers: `register_log_level` (parses an INI string into a `Logger::set_log_level` call) and `register_atomic<T>` for `int`/`bool`/`float` (writes the parsed value into a caller-supplied `std::atomic<T>` with `memory_order_relaxed`)
84+
- Convenience helpers: `register_log_level` (parses an INI string into a `Logger::set_log_level` call) and `register_atomic<T>` for `int`/`bool`/`float` (writes the parsed value into a caller-supplied `std::atomic<T>` with `memory_order_relaxed`; the 4-argument overload uses the atomic's current value as the registration default)
8585
- **Hot-reload** (see [Config Hot-Reload Guide](docs/config-hot-reload/README.md)):
8686
- `Config::reload()` re-runs every registered setter against the last-loaded INI without touching registrations; skips setters when the on-disk bytes are byte-identical to the last load (FNV-1a content hash)
8787
- `Config::enable_auto_reload()` starts a background `ConfigWatcher` (`config_watcher.hpp`) that debounces editor save-flurries and triggers `reload()` automatically; returns an `AutoReloadStatus` enum indicating outcome
@@ -301,6 +301,8 @@ See the [Config Hot-Reload Guide](docs/config-hot-reload/README.md) for the thre
301301
- Named key resolution uses binary search for efficient lookup
302302
- `register_press` and `register_hold` accept `KeyComboList` directly for zero-boilerplate binding of config-parsed key combos
303303
- Live registration: `register_press` / `register_hold` append bindings to a running poller, and `clear_bindings()` / `remove_binding_by_name()` drop bindings without stopping the poll thread, so consumers can re-arm input on hot-reload without a full restart
304+
- `Config::register_press_combo` / `Config::register_hold_combo` fuse an INI combo item, the matching `InputManager::register_press` / `register_hold` binding, automatic rebind on `reload()`, and an `InputBindingGuard` cancellation token into one call. The hold variant's guard synthesizes a single balancing `on_state_change(false)` when cancelled mid-hold, so tearing a hold down cannot strand the consumer in the held state
305+
- Both combo registrars take an optional per-binding `consume` facet: pass `consume` to register a `"<ini_key>.Consume"` bool inline (wired to `set_consume`) instead of a separate `register_consume_flag` call. `std::nullopt` (the default) registers no extra key and preserves the prior behavior exactly; suppression honors the same gamepad-digital + mouse-wheel scope noted above, so a `.Consume` key on a keyboard-only binding is inert
304306
305307
</details>
306308

docs/config-hot-reload/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ The `on_reload` callback passed to `enable_auto_reload` receives a `bool content
113113
- Numeric tunables: damage multipliers, timeouts, thresholds.
114114
- Feature flags that branch inside a hook callback.
115115
- Strings displayed in UI.
116-
- Key combos registered via `Config::register_press_combo`: the combo machinery calls `InputManager::update_binding_combos` on reload, which swaps keys/modifiers in place without re-registering the binding.
116+
- Key combos registered via `Config::register_press_combo` / `Config::register_hold_combo`: the combo machinery calls `InputManager::update_binding_combos` on reload, which swaps keys/modifiers in place without re-registering the binding. A `consume` facet passed to either registrar adds a `"<ini_key>.Consume"` bool that hot-reloads alongside the combo.
117117
118118
**Restart required** (reloading silently has no effect, or is actively unsafe):
119119

0 commit comments

Comments
 (0)