|
4 | 4 |
|
5 | 5 | DetourModKit is a C++23 static library for Windows game modding. It provides AOB scanning, function hooking (via SafetyHook), async logging, INI configuration, input polling, and memory utilities. The library is consumed by mod DLLs injected into game processes. |
6 | 6 |
|
7 | | -**Stack:** C++23, CMake 3.25+, Ninja, GoogleTest. Targets MinGW (GCC 12+) and MSVC 2022+. |
| 7 | +**Stack:** C++23, CMake 3.28+, Ninja, GoogleTest. Targets MinGW (GCC 12+) and MSVC 2022+. |
8 | 8 |
|
9 | 9 | **Key dependencies (git submodules):** |
10 | 10 |
|
@@ -262,7 +262,7 @@ Markdown files (`*.md`) are **not** hard-wrapped at 80 columns. Write one logica |
262 | 262 | - **Callbacks are host-critical:** Hook callbacks and input callbacks run on the game's threads. Do not perform unbounded allocation, blocking I/O, hook creation/removal, or config reload directly inside them; defer that work to a worker or queue. Logging from a callback must use the no-throw `Logger::log_noexcept` / `Logger::try_log` so a formatting or sink failure cannot escape into the host. |
263 | 263 | - **API-discipline labels:** Public docblocks classify a function's call-site safety with one of three labels, applied as a `@note` and kept alongside (never replacing) any existing more-specific caveat. *Callback-safe* -- non-blocking, no unbounded allocation, no blocking I/O, no lock escalation; safe to call from a hook or input callback on a game thread (the hot-path reads and status queries). *Setup/control-plane only* -- may block, allocate, take exclusive locks, or do I/O; call from init/shutdown or a worker thread, never from a hook or input callback (create/remove/enable/disable, start/stop/shutdown, config load/reload, cache init). *Best-effort* -- on failure it fails closed (no-op / false / dropped) and never throws or terminates the host (logging, diagnostics counters, `emit_safe`, noexcept fail-closed paths). |
264 | 264 | - **Error returns:** `std::expected` for memory operations, `std::optional` for scanner results. Reserve exceptions for construction failures and truly exceptional conditions. |
265 | | -- **Security hardening:** The build enables ASLR (`/DYNAMICBASE`), DEP (`/NXCOMPAT`), and Control Flow Guard (`/GUARD:CF`) on MSVC, and equivalent flags (`--dynamicbase`, `--nxcompat`) on MinGW. Because DetourModKit is a static archive (the consumer performs the final link of the mod DLL/EXE), these switches are also propagated to `find_package` / `add_subdirectory` consumers via `target_link_options(DetourModKit INTERFACE ...)`, selected from the linker frontend detected at configure time so the right spelling reaches MSVC/clang-cl and MinGW/Clang while preserving the CMake 3.25 minimum. Do not remove these. |
| 265 | +- **Security hardening:** The build enables ASLR (`/DYNAMICBASE`), DEP (`/NXCOMPAT`), and Control Flow Guard (`/GUARD:CF`) on MSVC, and equivalent flags (`--dynamicbase`, `--nxcompat`) on MinGW. Because DetourModKit is a static archive (the consumer performs the final link of the mod DLL/EXE), these switches are also propagated to `find_package` / `add_subdirectory` consumers via `target_link_options(DetourModKit INTERFACE ...)`, selected from the linker frontend detected at configure time so the right spelling reaches MSVC/clang-cl and MinGW/Clang while preserving the CMake 3.28 minimum. Do not remove these. |
266 | 266 |
|
267 | 267 | ### Lambda conventions |
268 | 268 |
|
@@ -308,7 +308,7 @@ dispatcher.emit_safe(PlayerStateChanged{.health = player->health}); |
308 | 308 |
|
309 | 309 | ### Memory access in hook callbacks |
310 | 310 |
|
311 | | -Do not add `Memory::is_readable()` or `Memory::is_writable()` before every field read in hook callbacks. Use those predicates for setup validation and diagnostics. Use `seh_read_chain` for unstable live game pointers, and use `read_ptr_unchecked` only when the caller can prove the pointer chain is live for the current frame. The full pattern -- worked examples, the primitive selection table, and the anti-patterns to remove -- lives in [docs/misc/hot-path-memory.md](docs/misc/hot-path-memory.md). |
| 311 | +Do not add `Memory::is_readable()` or `Memory::is_writable()` before every field read in hook callbacks. Use those predicates for setup validation and diagnostics. Use `seh_read_chain` for unstable live game pointers, and use `read_ptr_unchecked` only when the caller can prove the pointer chain is live for the current frame. For per-frame WRITES through a resolved address or chain, use the guarded `seh_write_chain` / `seh_write_chain_bytes` / `seh_write_bytes` family (the write counterpart of `seh_read_*`, with no protection change or i-cache flush); reserve `write_bytes` for one-shot code patches, since it flips page protection, flushes the instruction cache, and invalidates the cache range. The full pattern -- worked examples, the primitive selection table, and the anti-patterns to remove -- lives in [docs/misc/hot-path-memory.md](docs/misc/hot-path-memory.md). |
312 | 312 |
|
313 | 313 | ### Scanning process memory |
314 | 314 |
|
@@ -395,8 +395,9 @@ These are called at 60+ fps from game hook callbacks. Never add allocations, exc |
395 | 395 | - `Memory::is_readable_nonblocking()` -- try_lock_shared + cache lookup (returns Unknown on lock contention, a cache miss, or the init-publication window; falls back to a blocking VirtualQuery before `init_cache()`) |
396 | 396 | - `Memory::read_ptr_unsafe()` -- SEH-protected raw dereference (MSVC), vectored-handler-guarded read on MinGW (no per-call VirtualQuery; the fault guard also closes the stale-cache dereference) |
397 | 397 | - `Memory::read_ptr_unchecked()` -- inline pointer dereference with source and result user-mode range guards (a low-address floor plus a `USERSPACE_PTR_MAX` ceiling that rejects kernel-range and non-canonical values, which also subsumes pointer-arithmetic wraparound), no SEH (caller must guarantee structural pointer validity); debug builds add an `is_readable` assert that catches a stale or unmapped source pointer, compiled out in release so the hot path stays a single guarded memcpy |
398 | | -- `Memory::seh_read<T>()` / `seh_read_bytes()` -- typed and raw SEH-guarded reads; single `__try` frame on MSVC, and on MinGW a single `rep movsb` copy under a process-wide vectored exception handler (installed lazily / by `init_cache`, removed by `shutdown_cache`) that recovers via a non-unwinding `__builtin_setjmp`/`__builtin_longjmp`, so the success path runs no syscall. Both toolchains swallow the same foreign-read fault set -- `EXCEPTION_ACCESS_VIOLATION`, `STATUS_GUARD_PAGE_VIOLATION`, and `EXCEPTION_IN_PAGE_ERROR` (a file-backed or image-mapped page failing to page in, e.g. during an RTTI / section walk) -- via the shared predicate `Memory::detail::is_guarded_read_fault`, and let any other fault continue the handler search; the MinGW handler additionally claims only faults whose address lies in the foreign range being read. A guarded read uses a single per-thread guard, so it must not nest on one thread (DMK reads are synchronous, so it does not); if `AddVectoredExceptionHandler` ever fails the reads fall back to VirtualQuery validation. Used by `Rtti` for chained RTTI walks |
| 398 | +- `Memory::seh_read<T>()` / `seh_read_bytes()` -- typed and raw SEH-guarded reads; single `__try` frame on MSVC, and on MinGW a single `rep movsb` copy under a process-wide vectored exception handler (installed lazily / by `init_cache`, removed by `shutdown_cache`) that recovers via a non-unwinding `__builtin_setjmp`/`__builtin_longjmp`, so the success path runs no syscall. Both toolchains swallow the same foreign-read fault set -- `EXCEPTION_ACCESS_VIOLATION`, `STATUS_GUARD_PAGE_VIOLATION`, and `EXCEPTION_IN_PAGE_ERROR` (a file-backed or image-mapped page failing to page in, e.g. during an RTTI / section walk) -- via the shared predicate `Memory::detail::is_guarded_read_fault`, and let any other fault continue the handler search; the MinGW handler additionally claims only faults whose address lies in the foreign range being read. A guarded read uses a single per-thread guard, so it must not nest on one thread (DMK reads are synchronous, so it does not); if `AddVectoredExceptionHandler` ever fails the byte-copy reads fall back to VirtualQuery plus ReadProcessMemory, while bulk region scans fail closed. Used by `Rtti` for chained RTTI walks |
399 | 399 | - `Memory::seh_resolve_chain()` / `seh_read_chain<T>()` -- resolves or reads a whole multi-level pointer chain under one fault guard: one out-of-line call instead of N separate `seh_read` calls, with each intermediate link kept in a register and pre-screened by `plausible_userspace_ptr` (a faulting or implausible link aborts the walk and returns nullopt/false). On MinGW each link read is guarded by the vectored handler |
| 400 | +- `Memory::seh_write<T>()` / `seh_write_bytes()` / `seh_write_chain<T>()` / `seh_write_chain_bytes()` -- guarded per-frame WRITES to already-writable game memory (a camera transform, a player field), the write counterpart of the `seh_read_*` reads. MSVC uses one `__try` frame, and MinGW x64 uses the vectored-handler copy path with a fallback through VirtualQuery plus WriteProcessMemory, with no page-protection change, no instruction-cache flush, and no cache invalidation, so a stale chain fails closed (false) instead of faulting the host. `Memory::write_bytes()` is the setup/patch-only counterpart (it flips page protection, flushes the instruction cache, and invalidates the cache range); never put it on a per-frame path |
400 | 401 | - `Memory::plausible_userspace_ptr(p)` -- `inline constexpr` user-mode pointer plausibility test; pure arithmetic with no syscall and no memory access (early-rejects stale/sentinel/torn pointers before an SEH-guarded read) |
401 | 402 | - `Memory::contains(range, p)` -- constexpr point-in-range test for module bounds checks |
402 | 403 | - `Memory::own_module_range()` / `host_module_range()` -- magic-static cached, single atomic load on the fast path |
|
0 commit comments