Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions .github/workflows/pr-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ on:
workflow_dispatch:

env:
MINGW_BIN: C:\mingw64\bin
# Pin the exact GCC the release workflow ships with (Chocolatey MinGW 13.2.0) so the coverage/PR gate and the
# released artifact compile with one toolchain instead of the runner's unversioned C:\mingw64.
MINGW_BIN: C:\ProgramData\chocolatey\lib\mingw\tools\install\mingw64\bin

jobs:
build-test:
Expand All @@ -28,6 +30,18 @@ jobs:
with:
submodules: "recursive"

- name: Cache MinGW
id: cache-mingw
uses: actions/cache@v4
Comment thread
tkhquang marked this conversation as resolved.
with:
path: C:\ProgramData\chocolatey\lib\mingw
key: ${{ runner.os }}-mingw-13.2.0-v2

- name: Install MinGW (if not cached)
if: steps.cache-mingw.outputs.cache-hit != 'true'
run: choco install mingw --version=13.2.0 --yes --force --no-progress
shell: powershell

- name: Add MinGW to PATH
run: echo "${{ env.MINGW_BIN }}" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
shell: powershell
Expand All @@ -53,10 +67,17 @@ jobs:

- name: Verify Tools
run: |
echo "--- gcc ---" && gcc --version
echo "--- g++ ---" && g++ --version
echo "--- ninja ---" && ninja --version
echo "--- cmake ---" && cmake --version
echo "--- gcovr ---" && gcovr --version
# Surface the resolved toolchain in the job summary so the gate's GCC is visible without opening the log.
echo "### Toolchain" >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
gcc --version | head -1 >> "$GITHUB_STEP_SUMMARY"
g++ --version | head -1 >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
shell: bash

- name: Configure (Debug + Tests + Coverage)
Expand All @@ -70,7 +91,10 @@ jobs:
- name: Copy MinGW runtime DLLs
run: |
$testDir = "build\mingw-debug\tests"
Get-ChildItem "${{ env.MINGW_BIN }}\lib*.dll" | ForEach-Object {
# Resolve the runtime DLLs from the g++ that actually built the tests (off PATH) rather than a hard-coded
# Chocolatey subpath, whose bin layout varies by package version and may not exist on the runner image.
$mingwBin = Split-Path -Parent (Get-Command g++).Source
Get-ChildItem "$mingwBin\lib*.dll" -ErrorAction SilentlyContinue | ForEach-Object {
Copy-Item $_.FullName "$testDir\"
}
shell: powershell
Expand Down
9 changes: 5 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

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.

**Stack:** C++23, CMake 3.25+, Ninja, GoogleTest. Targets MinGW (GCC 12+) and MSVC 2022+.
**Stack:** C++23, CMake 3.28+, Ninja, GoogleTest. Targets MinGW (GCC 12+) and MSVC 2022+.

**Key dependencies (git submodules):**

Expand Down Expand Up @@ -262,7 +262,7 @@ Markdown files (`*.md`) are **not** hard-wrapped at 80 columns. Write one logica
- **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.
- **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).
- **Error returns:** `std::expected` for memory operations, `std::optional` for scanner results. Reserve exceptions for construction failures and truly exceptional conditions.
- **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.
- **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.

### Lambda conventions

Expand Down Expand Up @@ -308,7 +308,7 @@ dispatcher.emit_safe(PlayerStateChanged{.health = player->health});

### Memory access in hook callbacks

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).
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).

### Scanning process memory

Expand Down Expand Up @@ -395,8 +395,9 @@ These are called at 60+ fps from game hook callbacks. Never add allocations, exc
- `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()`)
- `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)
- `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
- `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
- `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
- `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
- `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
- `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)
- `Memory::contains(range, p)` -- constexpr point-in-range test for module bounds checks
- `Memory::own_module_range()` / `host_module_range()` -- magic-static cached, single atomic load on the fast path
Expand Down
4 changes: 2 additions & 2 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
cmake_minimum_required(VERSION 3.28)

project(DetourModKit VERSION 3.8.2 LANGUAGES CXX)
project(DetourModKit VERSION 3.9.0 LANGUAGES CXX)

# GNUInstallDirs defines CMAKE_INSTALL_LIBDIR / BINDIR / INCLUDEDIR / DOCDIR. It must be included before any
# install() rule reads those variables; otherwise they expand to empty and components land at the install-prefix
Expand Down Expand Up @@ -350,7 +350,7 @@ if(WIN32)
# stated explicitly, and /GUARD:CF activates the Control Flow Guard load config
# so DetourModKit's /guard:cf-instrumented code is enforced in the consumer
# binary. CMake's compiler-frontend generator expression is 3.30+, so keep the
# project compatible with its 3.25 minimum by detecting the active frontend at
# project compatible with its 3.28 minimum by detecting the active frontend at
# configure time. Release packages are compiler-specific, and add_subdirectory
# consumers share the same toolchain that configured this target.
set(_dmk_uses_msvc_linker_frontend OFF)
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ DetourModKit is a full-featured C++23 toolkit designed to simplify common tasks
| Configuration | INI-based settings with key combo support and hot-reload (file watcher + hotkey) | `config.hpp`, `config_watcher.hpp` |
| Logger | Synchronous singleton logger with format strings | `logger.hpp` |
| Async Logger | Lock-free bounded queue logger with batched writes | `async_logger.hpp` |
| Memory Utilities | Readability checks, region cache, safe pointer reads, typed SEH reads, PE module range queries | `memory.hpp` |
| Memory Utilities | Readability checks, region cache, safe pointer reads, typed SEH reads/writes, fault-guarded pointer-chain reads/writes, PE module range queries | `memory.hpp` |
| MSVC RTTI Walker | Recover mangled type names from runtime vtables; pointer-table scan with caller-owned cache; reverse name-to-vtable resolver and cached identity handle | `rtti.hpp` |
| RTTI Self-Heal | Reverse-identify the object behind a pointer slot (typed-error and ordered candidate-fallback forms); self-heal a field offset after a patch shifts the struct layout; rigid multi-field drift solver; drift-telemetry report with a durable, diffable manifest (open-failure distinguished from corrupt) | `rtti_dissect.hpp`, `drift_manifest.hpp` |
| Anchor Registry | One declarative table over the self-healing backends (vtable-by-name, AOB/RIP cascade, in-code constant, string xref, pinned literal) plus two-signal quorum corroboration with sub-anchor independence checks, optional post-resolve validators and opt-in validator policies, a manifest quality diagnostic, an address-independent evidence fingerprint for manifest diffing, opt-in parallel table resolution, and a per-game scan profile (broad-mode default, candidate order, backend deny-list), resolved and reported in a single pass | `anchors.hpp`, `profile.hpp` |
Expand Down Expand Up @@ -153,6 +153,8 @@ See the [Config Hot-Reload Guide](docs/config-hot-reload/README.md) for the thre
- `read_ptr_unsafe()` - safe pointer reads in hot paths (SEH-protected on MSVC, guarded by a process-wide vectored exception handler on MinGW, so the success path issues no per-call VirtualQuery)
- `read_ptr_unchecked()` - inline header-only variant with a configurable lower-bound guard plus a `USERSPACE_PTR_MAX` ceiling for pointer chain traversal without per-call SEH overhead (caller must guarantee structural pointer validity)
- `seh_read<T>()` / `seh_read_bytes()` - typed SEH-guarded reads for arbitrary trivially copyable T (and contiguous byte ranges), used to walk torn pointer chains and parse PE headers without per-site `__try` boilerplate. Returns `std::optional<T>` / `bool` so callers can distinguish "read faulted" from "read returned zero"
- `seh_resolve_chain()` / `seh_read_chain<T>()` / `seh_read_chain_bytes()` - resolve a multi-level pointer chain (Cheat-Engine semantics) and optionally read its terminal slot, all under one fault guard, so a torn or implausible link aborts the walk and returns `std::nullopt` / `false` instead of faulting the host
- `seh_write<T>()` / `seh_write_bytes()` / `seh_write_chain<T>()` / `seh_write_chain_bytes()` - the WRITE counterpart of the `seh_read_*` family: a typed value, raw bytes, or a value at the end of a resolved pointer chain, all written to already-writable game memory under one fault guard with no page-protection change, instruction-cache flush, or cache invalidation, so a stale chain fails closed. Use these for per-frame data writes (a camera transform, a player field); reserve `write_bytes()` for one-shot code patches, which flips page protection and flushes the instruction cache
- `module_range_for(addr)` / `own_module_range()` / `host_module_range()` - PE image range queries (base + SizeOfImage) for sanity-checking that a resolved vtable or return address lives inside the game image vs the heap or an injected DLL. Per-HMODULE cache for `module_range_for`; magic-static cache for the own and host variants
- `Memory::contains(range, p)` - constexpr point-in-range test

Expand Down
Loading
Loading