Skip to content

Commit e43eb5e

Browse files
authored
feat: hot-reload, cooperation, and convenience APIs (#77)
1 parent 9d023e6 commit e43eb5e

16 files changed

Lines changed: 2053 additions & 86 deletions

CMakeLists.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
cmake_minimum_required(VERSION 3.25)
22

3-
project(DetourModKit VERSION 3.2.1 LANGUAGES CXX)
3+
project(DetourModKit VERSION 3.2.2 LANGUAGES CXX)
44

55
# --- Standard and Compiler Options ---
66
set(CMAKE_CXX_STANDARD 23)

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ DetourModKit is a lightweight C++ toolkit designed to simplify common tasks in g
5050
- Per-object interception of virtual calls (e.g., D3D device methods, game AI interfaces)
5151
- Apply a single hooked vtable to multiple objects
5252
- Safe callback-based access to hooked methods via `with_vmt_method()`
53+
- **Convenience helpers**: `try_install_inline` / `try_install_inline_aob` / `try_install_mid` / `try_install_mid_aob` fuse `create_*_hook` with single-line Error logging on failure, returning `optional<string>` of the registered name
54+
- **Duplicate-target query**: `HookManager::is_target_already_hooked(addr)` reports whether the local registry already inline-hooks a given address (does not see hooks installed by other statically-linked DMK consumers in the same process)
5355

5456
</details>
5557

@@ -62,6 +64,7 @@ DetourModKit is a lightweight C++ toolkit designed to simplify common tasks in g
6264
- Format: `modifier+trigger` (e.g., `Ctrl+Shift+F3`)
6365
- Comma-separated independent combos (e.g., `F3,Gamepad_LT+Gamepad_B`)
6466
- Named keys (`Ctrl`, `F3`, `Mouse1`, `Gamepad_A`), hex VK codes (`0x72`), and mixed formats
67+
- 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`)
6568
- **Hot-reload** (see [Config Hot-Reload Guide](docs/config-hot-reload/README.md)):
6669
- `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)
6770
- `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
@@ -210,6 +213,7 @@ See the [Config Hot-Reload Guide](docs/config-hot-reload/README.md) for the thre
210213
- Load input codes from INI files (named keys, hex VK codes, or mixed)
211214
- Named key resolution uses binary search for efficient lookup
212215
- `register_press` and `register_hold` accept `KeyComboList` directly for zero-boilerplate binding of config-parsed key combos
216+
- 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
213217
214218
</details>
215219
@@ -224,6 +228,8 @@ See the [Config Hot-Reload Guide](docs/config-hot-reload/README.md) for the thre
224228
- `DMK_Shutdown()` is invoked unconditionally after the user shutdown function, guaranteeing the correct teardown order
225229
- `request_shutdown()` signals the worker to drain so a mod can trigger its own unload before `FreeLibrary` and keep teardown off the loader lock (see the [Hot-Reload Guide](docs/hot-reload/README.md))
226230
- Handles the `DLL_PROCESS_DETACH` process-exit vs dynamic-unload distinction automatically via `lpvReserved`
231+
- `on_logic_dll_unload(hook_names, binding_names)` drops only the per-Logic-DLL hooks and bindings owned by the caller, leaving Logger and Config alive for whichever container hosts the next Logic-DLL incarnation
232+
- `on_logic_dll_unload_all()` is the catch-all variant for callers without an explicit name registry; in a host that loads multiple Logic DLLs sharing one DMK instance, prefer the named-list overload because the catch-all rips out every Logic DLL's state
227233
228234
</details>
229235

docs/hot-reload/README.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1167,14 +1167,74 @@ A: If `Init()` throws, the loader catches nothing (C functions shouldn't throw a
11671167

11681168
---
11691169

1170+
## Topology Variant: Persistent Host with Swappable Logic DLLs
1171+
1172+
The standard two-DLL pattern above static-links DMK into the *Logic DLL*: each reload cycle destroys and reconstructs the DMK singletons alongside the rest of the mod state. This is the recommended default and works well when a single mod owns the process.
1173+
1174+
DMK also supports a second topology where DMK is static-linked into the **loader** (or a shared host module) and survives every Logic-DLL unload. This is the right choice when:
1175+
1176+
- One host needs to load several Logic DLLs that all use DMK (a shared loader plus per-feature modules), or
1177+
- A loader wants to keep `Logger`, `Config`, and the `ConfigWatcher` alive across reload cycles so log files and INI state are not torn down on every iteration, or
1178+
- The same Logic DLL is loaded, unloaded, and reloaded many times during a session and the host wants amortized cost on shared infrastructure.
1179+
1180+
In this topology, the Logic DLL must **not** call `DMK_Shutdown()` from `Shutdown()`, because doing so would tear down singletons that other Logic DLLs (or the next reload of this one) still depend on. Instead, it drops only the resources it owns:
1181+
1182+
```cpp
1183+
extern "C" __declspec(dllexport) void Shutdown()
1184+
{
1185+
using namespace std::string_view_literals;
1186+
1187+
static constexpr std::string_view hook_names[] = {
1188+
"camera_update"sv,
1189+
"visual_equip_change"sv,
1190+
};
1191+
static constexpr std::string_view binding_names[] = {
1192+
"ToggleEquip_Chest"sv,
1193+
"ShowEquip_Chest"sv,
1194+
};
1195+
1196+
DMKBootstrap::on_logic_dll_unload(hook_names, binding_names);
1197+
}
1198+
```
1199+
1200+
`Bootstrap::on_logic_dll_unload(hook_names, binding_names)` removes the named hooks via `HookManager::remove_hook` (which restores the original prologue bytes through SafetyHook) and clears the named input bindings via `InputManager::remove_binding_by_name`. It is `noexcept`, idempotent, and safe to call multiple times: a second call with the same names is a no-op. Logger, Config, and the ConfigWatcher are intentionally left running.
1201+
1202+
`Bootstrap::on_logic_dll_unload_all()` is the catch-all variant for callers that do not maintain an explicit registry of hook or binding names. It composes `HookManager::remove_all_hooks` with `InputManager::clear_bindings` under the same `noexcept`, idempotent, exception-swallowing contract; Logger, Config, and the ConfigWatcher are again left running. The HookManager teardown call is `remove_all_hooks()` rather than `shutdown()` so the manager stays re-usable for the next attach, and the binding teardown call is `clear_bindings()` rather than `InputManager::shutdown()` so the poll thread keeps running idle. Use this overload when one Logic DLL owns the entire DMK instance and a single Logic DLL teardown should drain everything.
1203+
1204+
Prefer the named-list overload when the host loads several Logic DLLs that share one DMK instance: calling `on_logic_dll_unload_all()` from one Logic DLL's `Shutdown()` rips out every other Logic DLL's hooks and bindings as well, because the singletons are process-scoped. The named-list overload keeps each Logic DLL's teardown scoped to the names it registered.
1205+
1206+
`Init()` re-registers hooks and bindings as it normally would. `HookManager::create_*_hook` uses replace-on-duplicate semantics, and `InputManager::register_press` / `register_hold` insert into a live poller without restarting it (see `clear_bindings()` if a wholesale reset is preferred).
1207+
1208+
When choosing between the two topologies:
1209+
1210+
| Concern | DMK in Logic DLL (default) | DMK in Loader (persistent host) |
1211+
|-----------------------------|-----------------------------------------|-----------------------------------------------|
1212+
| Singleton lifetime | One reload cycle | Process lifetime |
1213+
| Logger / Config / Watcher | Reconstructed each reload | Outlive every Logic-DLL unload |
1214+
| Logic-DLL `Shutdown()` does | `DMK_Shutdown()` | `Bootstrap::on_logic_dll_unload(...)` |
1215+
| Process-exit cleanup | `DMK_Shutdown()` from final `Shutdown()`| `DMK_Shutdown()` from the host's `DllMain` |
1216+
| Multiple Logic DLLs | Each ships its own DMK copy | One DMK instance shared by all |
1217+
1218+
Mixing the two in one process is not supported: pick one per host module and stay on it.
1219+
1220+
---
1221+
11701222
## Hot-Reload Safety Guarantees
11711223

11721224
DetourModKit's core systems are designed to be safe across DLL reload cycles:
11731225

11741226
**HookManager:** `shutdown()` and `remove_all_hooks()` both use a two-phase removal pattern: hooks are disabled under a shared lock first (allowing in-flight trampoline callers to drain), then the hook maps are cleared under an exclusive lock. This prevents deadlock when a hooked thread is blocked on `m_hooks_mutex` via `with_inline_hook()`. Both methods reset internal state afterward, allowing subsequent `create_*_hook()` calls to succeed. There is no need to call both - either one prepares the HookManager for reuse.
11751227

1228+
`HookManager::is_target_already_hooked(addr)` reports whether the local `HookManager` instance already has an inline or mid hook installed at `addr`. It is the programmatic counterpart to the install-time WARNING that fires when SafetyHook layers on top of a pre-existing JMP from another module, and it covers the common case of two cooperating modules wanting to coordinate hook ownership without grepping log output.
1229+
1230+
**InputManager:** `register_press` and `register_hold` accept new bindings whether the poller is stopped, starting, or running. Live registration takes the poller's exclusive lock, appends to the binding list, and rebuilds the parallel `active_states_` array in one step, so there is no per-tick allocation in the hot loop. Surviving entries' atomic states are carried forward across every reshape (`add_binding`, `remove_bindings_by_name`, `update_binding_combos`), so a held binding never flickers through one inactive tick when an unrelated binding is added or dropped. `clear_bindings()` empties the registry without stopping the poller, and `remove_binding_by_name(name)` drops a single binding by name (used internally by `Bootstrap::on_logic_dll_unload`). `update_binding_combos(name, combos)` accepts cardinality changes (1 to N or N to 1) and replaces the registered combo list wholesale; supplying an empty list is rejected so an INI typo cannot silently disable a binding. When a cardinality change drops a held `register_hold` entry, that entry's `on_state_change(false)` release callback fires before the rebuild completes so the consumer never latches in the held state.
1231+
1232+
**Bootstrap unload helpers:** `Bootstrap::on_logic_dll_unload(_all)` deliberately drops bindings without firing `on_state_change(false)` release callbacks, because user callbacks live in the unloading Logic DLL and running them under the Windows loader lock is the deadlock-or-crash vector that the v3.2.1 leak-on-purpose discipline forbids. Consumers that need a clean release on a planned unload (not driven by `DllMain` detach) should call `InputManager::clear_bindings()` or `remove_binding_by_name(name)` directly before invoking the unload helper.
1233+
11761234
**Config:** `register_*()` functions use replace-on-duplicate semantics. If a new DLL registers a config item with the same section and INI key as an existing entry, the old registration is replaced rather than appended. This prevents doubled registrations across reload cycles without requiring an explicit `clear_registered_items()` call. Calling `clear_registered_items()` before re-registration is still supported but no longer required.
11771235

1236+
`Config::register_press_combo` with an empty default registers the binding name with no keys, so a later `update_binding_combos` call (typically driven by an INI edit and `AutoReloadConfig`) can attach real combos to it. The previous behavior, where empty defaults were dropped from the registry, is gone.
1237+
11781238
---
11791239

11801240
## Related Documentation

include/DetourModKit/bootstrap.hpp

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
#include "DetourModKit/async_logger.hpp"
1010

1111
#include <functional>
12+
#include <span>
1213
#include <string>
1314
#include <string_view>
1415

@@ -125,6 +126,81 @@ namespace DetourModKit
125126
* after on_dll_detach().
126127
*/
127128
[[nodiscard]] HMODULE module_handle() noexcept;
129+
130+
/**
131+
* @brief Drops the per-Logic-DLL state owned by the caller.
132+
* @details Composes HookManager::remove_hook for every entry in
133+
* @p hook_names and InputManager::remove_binding_by_name
134+
* for every entry in @p binding_names. Names that do not
135+
* exist are skipped (logged at Debug). Idempotent: a
136+
* second call with the same names is a no-op.
137+
*
138+
* Does NOT touch Logger or Config: callers that share a
139+
* host process across multiple Logic-DLL incarnations
140+
* want those subsystems to outlive the unload. Use
141+
* DMK_Shutdown() for whole-process teardown.
142+
*
143+
* Safe to call from any thread except: must NOT be
144+
* called from the InputPoller thread (would self-join)
145+
* and must NOT be called from a HookManager mutator
146+
* callback (would deadlock on m_mutator_gate).
147+
*
148+
* Marked noexcept because consumers may invoke it from a
149+
* DllMain detach path. Internal allocations performed
150+
* while iterating the name spans (vector growth, log
151+
* formatting) may throw; every throw site is caught and
152+
* logged so no exception propagates to the caller.
153+
*
154+
* @param hook_names Names of hooks installed via HookManager.
155+
* @param binding_names Names of input bindings registered via
156+
* InputManager (or via Config::register_press_combo).
157+
*/
158+
void on_logic_dll_unload(std::span<const std::string_view> hook_names,
159+
std::span<const std::string_view> binding_names) noexcept;
160+
161+
/**
162+
* @brief Drops every hook and binding registered through the
163+
* process-wide singletons.
164+
* @details Composes HookManager::remove_all_hooks with
165+
* InputManager::clear_bindings under the same
166+
* loader-lock-safe, idempotent, exception-swallowing
167+
* contract as the named-list overload. Use when the
168+
* caller does not maintain an explicit registry of
169+
* hook or binding names.
170+
*
171+
* Logger, Config, and the ConfigWatcher are
172+
* intentionally left running, matching the named-list
173+
* overload's partition between per-Logic-DLL state and
174+
* host-owned infrastructure. Use DMK_Shutdown() for
175+
* whole-process teardown.
176+
*
177+
* The HookManager teardown call is remove_all_hooks(),
178+
* not shutdown(): both perform the same two-phase
179+
* drain, but remove_all_hooks() resets the shutdown
180+
* flag at the end so subsequent create_*_hook() calls
181+
* succeed for the next Logic-DLL incarnation. The
182+
* binding teardown call is clear_bindings(), not
183+
* shutdown(): the poller keeps running idle and is
184+
* ready to accept fresh bindings on the next attach.
185+
*
186+
* Safe to call from any thread except: must NOT be
187+
* called from the InputPoller thread (would self-join)
188+
* and must NOT be called from a HookManager mutator
189+
* callback (would deadlock on m_mutator_gate).
190+
*
191+
* Marked noexcept because consumers may invoke it from
192+
* a DllMain detach path. Internal allocations performed
193+
* while logging or rebuilding active_states_ may throw;
194+
* every throw site is caught and logged so no exception
195+
* propagates to the caller.
196+
*
197+
* @warning In a host that loads multiple Logic DLLs sharing one
198+
* process-wide DMK instance, calling this from one
199+
* Logic DLL's Shutdown() rips out the other Logic DLLs'
200+
* hooks and bindings as well. The named-list overload
201+
* is the correct choice in that topology.
202+
*/
203+
void on_logic_dll_unload_all() noexcept;
128204
} // namespace Bootstrap
129205
} // namespace DetourModKit
130206

0 commit comments

Comments
 (0)