You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
| HookManager | SRWLOCK-backed reader/writer locks; two-phase shutdown (disable under shared lock, clear under exclusive lock), both phases walking `m_hook_creation_order` in reverse so inline/mid hooks layered on one address unwind newest-first instead of in bucket order; `m_mutator_gate` blocks new mutators (including all VMT operations) during teardown; CAS on `m_shutdown_called` serializes shutdown/remove_all_hooks; double-checked fast-fail on `m_shutdown_called` in all mutators; every public mutation or teardown entry point also fails closed (`HookError::ReentrantCallRejected`, a false/zero result, or a logged no-op for void lifecycle calls) when the per-thread reentrancy guard is set, so a call from inside a `with_*` callback returns or no-ops instead of recursively acquiring the non-recursive lock; the batch toggles (`enable_hooks`/`disable_hooks`/`*_all`) collect their log lines under the lock and emit them after release so a synchronous sink flush or a blocking async overflow never stalls an exclusive acquirer; destructor fallback (when `DMK_Shutdown()` was not called) acquires `m_mutator_gate` exclusively, flips `m_shutdown_called`, drains readers via exclusive `m_hooks_mutex`, then clears the maps -- under loader lock it pins the module and swaps each map's contents into heap storage allocated via `new (std::nothrow)` so the storage outlives the destructor without ever draining, mirroring the leak-on-loader-lock discipline used in `Logger::shutdown_internal` and `ConfigWatcher::~ConfigWatcher` | `shared_lock` SRWLOCK reader for `with_inline_hook()` |
359
359
| Logger |`atomic<shared_ptr>` for lock-free async reads; `shutdown_internal` and `disable_async_mode` are safe across repeated shutdown / enable_async_mode cycles: when the writer thread has to be detached under loader lock, the module is pinned and the `shared_ptr<AsyncLogger>` is moved into a per-call permanent cell (normal path: `new (std::nothrow)`; fallback path: non-CRT permanent storage), so a heap allocation failure cannot drop the last handle while the writer may still be running | Single atomic load on log level check |
360
360
| AsyncLogger | Lock-free MPMC queue (Vyukov-style); post-join drain on shutdown (at most one message per producer can be lost in the nanosecond race between drain and force-zero -- accepted trade-off to avoid atomic overhead on every enqueue); timestamp caching in write batches | Atomic sequence numbers per slot |
361
-
| InputPoller | Atomic `m_active_states[]` array|`shared_lock` (uncontended SRWLOCK reader) guarding the `m_active_states` pointer swap + `memory_order_relaxed` load per binding |
361
+
| InputPoller | Atomic `m_active_states[]` array; the poll thread re-reserves its deferred-callback staging vector to the live binding count each cycle and stages it under a catch, so a runtime binding growth past the startup reserve cannot reallocate-then-throw out of the `jthread` body; a failed callback batch is dropped, not fatal |`shared_lock` (uncontended SRWLOCK reader) guarding the `m_active_states` pointer swap + `memory_order_relaxed` load per binding; keyboard/mouse reads route through a poll-thread-private per-cycle `KeyStateCache` so each distinct VK gets one coherent `GetAsyncKeyState` sample per cycle, not one call per binding reference|
362
362
| 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) |
363
363
| 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 |
@@ -421,3 +421,5 @@ These are called at 60+ fps from game hook callbacks. Never add allocations, exc
421
421
-**Do not key** a cache store and its invalidation/eviction by different addresses or shard-selection functions. Eviction must use the same canonical key and the same containment lookup as insertion and read, or entries silently survive invalidation (see `Memory::invalidate_range`, which scans every shard because storage is sharded by query address, not region base).
422
422
-**Do not let** a queue or backlog fed at an external event rate grow without bound -- clamp the pending count to a documented ceiling (see `Input` wheel-notch `MAX_WHEEL_PENDING`).
423
423
-**Do not let** an async/deferred sink diverge from its synchronous counterpart's configuration (timestamp format, level, etc.) -- carry the same settings through, as `Logger::enable_async_mode` does for the timestamp format.
424
+
-**Do not open** a shared append-mode file with `GENERIC_WRITE` plus a one-time `SetFilePointer(FILE_END)` seek -- request `FILE_APPEND_DATA` so the OS positions every write at the current end of file atomically and concurrent appenders cannot interleave or overwrite each other (see `WinFileStreamBuf::open`). Truncating (`out`) mode keeps `GENERIC_WRITE` + `CREATE_ALWAYS`.
425
+
-**Do not drop** the device source when formatting an off-table input code -- a non-keyboard code must serialize in the source-tagged hex form (`Mouse:0xNN`) that `parse_input_name` reads back, or it silently round-trips to a keyboard key (see `format_input_code` / `parse_input_name`).
Copy file name to clipboardExpand all lines: README.md
+4-1Lines changed: 4 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -259,6 +259,7 @@ See the [Config Hot-Reload Guide](docs/config-hot-reload/README.md) for the thre
259
259
- Hash-map-backed `is_binding_active()` query for low-overhead cross-thread state reads (e.g., from render hooks at 60+ fps)
260
260
- SRWLOCK-backed reader/writer synchronization for live binding reshapes, using the same native Windows lock primitive as hook registries and memory caches
261
261
- Multiple bindings per name for multi-combo hotkeys
262
+
- Keyboard and mouse virtual-key reads are memoized once per distinct VK per poll cycle, giving every binding in that cycle one coherent sample while avoiding duplicate `GetAsyncKeyState` calls
262
263
- Lock-free `is_running()` via atomic flag
263
264
- O(1) reverse name lookup for `input_code_to_name()`
264
265
@@ -1007,13 +1008,15 @@ The configuration system recognizes the following named input codes (case-insens
Hex VK codes with `0x` prefix (e.g., `0x72` for F3) are also accepted and default to keyboard input.
1019
+
Hex VK codes with `0x` prefix (e.g., `0x72` for F3) are also accepted and default to keyboard input. A code that has no table name but is not a keyboard code is written back to the INI in a source-tagged hex form (`Mouse:0xFE`, `Gamepad:0x800`, `MouseWheel:0x9`) and parsed back to the same device source, so a non-keyboard code survives a config round-trip instead of decaying to a keyboard key.
Copy file name to clipboardExpand all lines: docs/tests/README.md
+13Lines changed: 13 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -303,6 +303,18 @@ These tests enable the test-only `debug_snapshot_use_count()` accessor via `#def
303
303
304
304
The decoder header lives under `src/` (not the public include tree), so the test file adds `src/` to its include path and uses `DetourModKit::detail::` directly.
305
305
306
+
## Input Tests
307
+
308
+
`tests/test_input.cpp` covers public input-code parsing/formatting plus `InputPoller` / `InputManager` lifecycle and live binding reshapes. The poll-loop hot-path helpers are covered with focused tests because the real loop reads process input state:
309
+
310
+
| Test | What it proves |
311
+
| ---- | -------------- |
312
+
|`KeyStateCacheTest.ProbesEachDistinctVkOncePerCycle`| The per-cycle VK cache invokes the injected probe once for repeated reads of one VK, while distinct VKs get distinct samples. |
313
+
|`KeyStateCacheTest.ResetReArmsForNextCycle`|`reset()` clears the cycle snapshot so the next poll cycle samples the VK again. |
314
+
|`KeyStateCacheTest.CachesUpStateWithoutReProbing`| A released key is cached as a real sampled state, not confused with "not yet probed". |
315
+
|`KeyStateCacheTest.OutOfRangeVkReadsAsNotPressedWithoutProbing`| Invalid VK codes fail closed and never call the probe. |
316
+
|`InputPollerPollLoopSafety.BindingGrowthPastStartupReserveKeepsPollThreadAlive`| Live binding growth past the startup callback-staging reserve does not stop the poll thread. |
317
+
306
318
## Input Interception Tests
307
319
308
320
`tests/test_input_intercept.cpp` exercises the internal header `src/input_intercept.hpp`, the opt-in active-input layer that backs mouse-wheel capture and gamepad passthrough suppression for `InputPoller`. It uses the same `src/`-on-include-path pattern as the decoder tests. The unit tests target the two pure state machines that carry no Win32 dependency, so each is driven by direct calls with hand-supplied state:
@@ -332,6 +344,7 @@ The window-procedure subclass and the `XInputGetState` inline hook are exercised
332
344
|`InterceptWndProcTest.ConsumeSwallowsOwnedWheelMessages`| With consume off the notch is latched and forwarded to the game's predecessor procedure; with consume on it is still latched but swallowed so the game never sees it. |
333
345
|`InterceptWndProcTest.WmNcDestroySelfHealsAndAllowsResubclass`| Destroying the subclassed window marks the subclass uninstalled via `WM_NCDESTROY`, so a recreated window (the fullscreen-toggle case) can be re-subclassed. |
334
346
|`InterceptWndProcTest.UninstallRestoresPredecessorAtTopOfChain`| When the detour is still the top of the window-procedure chain, `uninstall()` restores the exact saved predecessor. |
347
+
|`InterceptWndProcTest.PollerDropsCallbackStagingCopyFailureAndContinues`| A wheel edge drives the poll-loop `PendingCallback` staging path with a callback whose copy throws; the failed callback batch is dropped, the poll thread stays alive, and a later edge dispatches normally. |
335
348
|`InterceptXInputTest.InstallHooksExportAndTrampolineRoundTrips`| Installing hooks the real `XInputGetState` export, publishes a non-null trampoline, is idempotent, routes a call through the detour into the trampoline, and restores the prologue on `uninstall()`. |
336
349
|`InterceptDisarmTest.PollerDisarmsWheelConsumeAfterClearBindings`| A standalone `InputPoller` with a consume wheel binding arms the swallow flag; `clear_bindings(false)` (the loader-lock-safe hot-reload reset) lets the poll loop disarm it on a later cycle so the game regains its wheel. |
0 commit comments