Skip to content

Commit f0cc945

Browse files
authored
feat: mod authoring helpers (#68)
- Scanner::resolve_cascade and resolve_cascade_with_prologue_fallback for multi-candidate AOB resolution with RIP and hooked-prologue recovery, guarded by literal-tail and module-bounds checks - HookManager cross-module duplicate-hook detection (E9 rel32, EB rel8, FF 25 rip-indirect), HookError::TargetAlreadyHookedInProcess - Config::register_press_combo with InputBindingGuard RAII and live update via InputManager::update_binding_combos - Filesystem::get_runtime_directory_utf8 UTF-8 sibling (cached) - DMK::Bootstrap DllMain lifecycle helper with loader-lock-safe detach and request_shutdown pre-unload contract - DMK::StoppableWorker RAII jthread wrapper with loader-lock-aware destruction (pin-then-detach) - Logger::configure timestamp_fmt default argument Adds 13 test cases (bootstrap, worker, scanner cascade hard paths, hook duplicate detection, concurrent input rebind). 932 tests pass.
1 parent cfcb2fa commit f0cc945

30 files changed

Lines changed: 2612 additions & 147 deletions

AGENTS.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,9 @@ include/DetourModKit/ # Public headers -- one per module
9595
version.hpp # Version macros (generated by CMake from version.hpp.in)
9696
format.hpp # std::format utilities + string trim (header-only)
9797
math.hpp # Angle conversions (header-only)
98-
filesystem.hpp # Module directory resolution (wide-string API)
98+
filesystem.hpp # Module directory resolution (wide-string and UTF-8 APIs)
99+
bootstrap.hpp # DllMain lifecycle (worker thread, mutex, process gate)
100+
worker.hpp # StoppableWorker RAII std::jthread wrapper
99101
src/ # Implementation files (one .cpp per module)
100102
tests/ # GoogleTest suites (one test_*.cpp per module)
101103
fixtures/ # Test support files (hook_target_lib DLL source)

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.0.0 LANGUAGES CXX)
3+
project(DetourModKit VERSION 3.1.0 LANGUAGES CXX)
44

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

README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,21 @@ DetourModKit is a lightweight C++ toolkit designed to simplify common tasks in g
1111

1212
| Module | Description | Header |
1313
|--------|-------------|--------|
14-
| AOB Scanner | SIMD-accelerated pattern scanning with wildcards and RIP resolution | `scanner.hpp` |
15-
| Hook Manager | Inline, mid-function, and VMT hooks via SafetyHook | `hook_manager.hpp` |
14+
| AOB Scanner | SIMD-accelerated pattern scanning with wildcards, RIP resolution, and multi-candidate cascade resolver with prologue fallback | `scanner.hpp` |
15+
| Hook Manager | Inline, mid-function, and VMT hooks via SafetyHook with cross-module duplicate-hook detection | `hook_manager.hpp` |
1616
| Configuration | INI-based settings with key combo support | `config.hpp` |
1717
| Logger | Synchronous singleton logger with format strings | `logger.hpp` |
1818
| Async Logger | Lock-free bounded queue logger with batched writes | `async_logger.hpp` |
1919
| Memory Utilities | Readability checks, region cache, and safe pointer reads | `memory.hpp` |
2020
| Event Dispatcher | Typed pub/sub with RAII subscriptions | `event_dispatcher.hpp` |
2121
| Profiler | Scoped timing with Chrome Tracing export (zero-cost when disabled) | `profiler.hpp` |
2222
| Format Utilities | `std::format` helpers for addresses, bytes, and VK codes; string trim | `format.hpp` |
23-
| Filesystem Utilities | Module directory resolution (wide-string API) | `filesystem.hpp` |
23+
| Filesystem Utilities | Module directory resolution (wide-string and UTF-8 APIs) | `filesystem.hpp` |
2424
| Math Utilities | Angle conversions (header-only) | `math.hpp` |
2525
| Version Macros | Compile-time version checking generated from CMake | `version.hpp` |
2626
| Input System | Hotkey monitoring with background polling (keyboard/mouse/gamepad) | `input.hpp`, `input_codes.hpp` |
27+
| Mod Bootstrap | DllMain scaffolding, instance mutex, process gate, lifecycle worker | `bootstrap.hpp` |
28+
| Stoppable Worker | RAII named `std::jthread` wrapper, loader-lock-safe teardown | `worker.hpp` |
2729

2830
<details>
2931
<summary><strong>AOB Scanner</strong></summary>
@@ -129,7 +131,7 @@ DetourModKit is a lightweight C++ toolkit designed to simplify common tasks in g
129131
<summary><strong>Format, Filesystem, Math, and Version Utilities</strong></summary>
130132

131133
- **Format** (`format.hpp`): Inline formatting helpers for memory addresses, byte values, VK codes, and hex integer vectors using `std::format`. Also includes string trim utilities.
132-
- **Filesystem** (`filesystem.hpp`): Module directory resolution (wide-string API).
134+
- **Filesystem** (`filesystem.hpp`): Module directory resolution via `get_runtime_directory()` (wide-string) and `get_runtime_directory_utf8()` (UTF-8).
133135
- **Math** (`math.hpp`): Angle conversions (header-only).
134136
- **Version** (`version.hpp`): Compile-time version checking via `DMK_VERSION_MAJOR`, `DMK_VERSION_MINOR`, `DMK_VERSION_PATCH`, `DMK_VERSION_STRING`, and `DMK_VERSION_AT_LEAST(major, minor, patch)`. Generated from CMake's `project(VERSION)` at configure time.
135137

docs/misc/aob-signatures.md

Lines changed: 122 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,17 @@ Practical reference for building, maintaining, and resolving array-of-bytes (AOB
99
3. [DMK pattern syntax reference](#3-dmk-pattern-syntax-reference)
1010
4. [Scanner API tour](#4-scanner-api-tour)
1111
5. [RIP-relative resolution](#5-rip-relative-resolution)
12-
6. [Patch-proof patterns (cache, fallback, verify)](#6-patch-proof-patterns-cache-fallback-verify)
13-
7. [Worked examples](#7-worked-examples)
14-
8. [DOs and DON'Ts](#8-dos-and-donts)
15-
9. [Troubleshooting](#9-troubleshooting)
16-
10. [Further reading](#10-further-reading)
12+
6. [Cascading candidates](#6-cascading-candidates)
13+
- 6.1 [Motivation](#61-motivation)
14+
- 6.2 [API shape](#62-api-shape)
15+
- 6.3 [Basic usage](#63-basic-usage)
16+
- 6.4 [Prologue fallback variant](#64-prologue-fallback-variant)
17+
- 6.5 [Ordering and logging](#65-ordering-and-logging)
18+
7. [Patch-proof patterns (cache, fallback, verify)](#7-patch-proof-patterns-cache-fallback-verify)
19+
8. [Worked examples](#8-worked-examples)
20+
9. [DOs and DON'Ts](#9-dos-and-donts)
21+
10. [Troubleshooting](#10-troubleshooting)
22+
11. [Further reading](#11-further-reading)
1723

1824
---
1925

@@ -261,11 +267,108 @@ const auto resolved = sc::find_and_resolve_rip_relative(
261267
- Indirect calls through memory: `FF 15 disp32` and `FF 25 disp32`. The disp32 points to a **pointer**; DMK returns the pointer's address, not the final target. Dereference it yourself.
262268
- Instructions where the disp32 is interrupted by a SIB byte combination or a VEX/EVEX prefix boundary: supply your own longer `opcode_prefix` that covers up to the disp32 start.
263269

264-
## 6. Patch-proof patterns (cache, fallback, verify)
270+
## 6. Cascading candidates
271+
272+
### 6.1 Motivation
273+
274+
Game binaries change across patches. A single literal AOB that locked onto a specific opcode window in one build is one compiler flag flip away from matching nothing on the next update. The cascade pattern is the standard defence: register several ordered candidates per target (most-specific first, most-generic last), let the scanner try each until one matches, and record the winner so you know which build of the game is actually running. Every long-lived modding community reinvents this eventually; DMK ships it as a first-class API so you do not have to reinvent the logging, the ordering rules, or the prologue-overwrite recovery path.
275+
276+
### 6.2 API shape
277+
278+
Defined in [include/DetourModKit/scanner.hpp](../../include/DetourModKit/scanner.hpp) inside `namespace DetourModKit::Scanner`:
279+
280+
```cpp
281+
enum class ResolveMode : std::uint8_t
282+
{
283+
Direct, // Returned address = match + disp_offset
284+
RipRelative // Read int32 disp at (match + disp_offset); target = match + instr_end_offset + disp
285+
};
286+
287+
struct AddrCandidate
288+
{
289+
std::string_view name;
290+
std::string_view pattern;
291+
ResolveMode mode = ResolveMode::Direct;
292+
std::ptrdiff_t disp_offset = 0;
293+
std::ptrdiff_t instr_end_offset = 0;
294+
};
295+
296+
enum class ResolveError : std::uint8_t
297+
{
298+
EmptyCandidates,
299+
NoMatch,
300+
AllPatternsInvalid,
301+
PrologueFallbackNotApplicable
302+
};
303+
304+
struct ResolveHit
305+
{
306+
std::uintptr_t address{0};
307+
std::string_view winning_name;
308+
};
309+
310+
[[nodiscard]] std::expected<ResolveHit, ResolveError>
311+
resolve_cascade(std::span<const AddrCandidate> candidates, std::string_view label);
312+
313+
[[nodiscard]] std::expected<ResolveHit, ResolveError>
314+
resolve_cascade_with_prologue_fallback(std::span<const AddrCandidate> candidates,
315+
std::string_view label);
316+
```
317+
318+
Both functions take a span so you can pass a `std::array`, `std::vector`, or any contiguous container. The `label` is the human-readable tag used when the winning candidate is logged; the `winning_name` on the returned `ResolveHit` aliases the matched candidate's `name` field, so the storage that holds those `string_view`s must outlive the hit (static string literals or an `std::array` in static storage are the usual patterns). `ResolveHit::address` is the post-resolution absolute address: for `Direct` candidates it equals `match + disp_offset`, and for `RipRelative` candidates it is the target of the displacement already resolved (not the raw match pointer), so callers can hook or call it directly.
319+
320+
### 6.3 Basic usage
321+
322+
```cpp
323+
#include <DetourModKit/scanner.hpp>
324+
#include <DetourModKit/logger.hpp>
325+
#include <array>
326+
327+
namespace sc = DetourModKit::Scanner;
328+
329+
constexpr std::array<sc::AddrCandidate, 3> k_weapon_fire_candidates{{
330+
{"weapon_fire_v1_8_2", "48 89 5C 24 ?? 57 48 83 EC 30 48 8B D9 48 8B FA",
331+
sc::ResolveMode::Direct, 0, 0},
332+
{"weapon_fire_v1_9_0", "40 53 48 83 EC 20 48 8B D9 E8 ?? ?? ?? ?? 84 C0",
333+
sc::ResolveMode::Direct, 0, 0},
334+
{"weapon_fire_callsite", "E8 ?? ?? ?? ?? 48 8B CB 48 8B 43 20",
335+
sc::ResolveMode::RipRelative, 1, 5},
336+
}};
337+
338+
const auto hit = sc::resolve_cascade(k_weapon_fire_candidates, "weapon_fire");
339+
if (!hit)
340+
{
341+
DetourModKit::Logger::get_instance().error(
342+
"weapon_fire cascade failed: {}", sc::resolve_error_to_string(hit.error()));
343+
return false;
344+
}
345+
346+
DetourModKit::Logger::get_instance().info(
347+
"resolved {} at {:#x}", hit->winning_name, hit->address);
348+
```
349+
350+
### 6.4 Prologue fallback variant
351+
352+
`resolve_cascade` is fine when the target function still looks the way your signature remembers it. It stops working as soon as another mod, loaded earlier in the process, inline-hooks the same function: SafetyHook, MinHook, and most hand-rolled detour libraries overwrite the first five bytes with a near-JMP (`E9 ?? ?? ?? ??`) to their trampoline. Your Direct-mode candidate that matches on a prologue byte sequence now sees `E9` instead of `48 89 5C 24 ...`, and the scan misses even though the function itself is still present.
353+
354+
`resolve_cascade_with_prologue_fallback` handles that exact scenario. On the happy path it is identical to `resolve_cascade`. If every candidate misses, it walks the list again and, for each Direct-mode candidate, rebuilds the pattern with the first five tokens replaced by `E9 ?? ?? ?? ??` while preserving the literal tail. It scans with the rewritten pattern and then applies two guardrails before accepting a hit: the rewritten pattern must match at most four locations in `.text` (so a near-JMP into random padding does not win), and the `E9` displacement must land inside a loaded module (so the jump target is a real function, not garbage). RipRelative candidates are skipped in the fallback phase since they target instructions deeper than the 5-byte prologue and are unaffected by the overwrite.
355+
356+
```cpp
357+
const auto hit = sc::resolve_cascade_with_prologue_fallback(
358+
k_weapon_fire_candidates, "weapon_fire");
359+
```
360+
361+
There is one guardrail callers must be aware of. The fallback refuses to scan any candidate whose literal tail after the first five tokens contains fewer than five literal bytes, and surfaces that refusal as `ResolveError::PrologueFallbackNotApplicable`. A too-short tail would produce a pattern that matches every near-JMP in the executable region and invent a false positive; rather than gamble, the cascade treats the candidate as unusable for recovery. If you see this error, extend the offending candidate's pattern so it carries at least five literal bytes past the five-byte prologue window.
362+
363+
### 6.5 Ordering and logging
364+
365+
Put the most-specific candidate first. The cascade returns on the first successful resolution, so an overly-generic pattern placed near the head will shadow tighter patterns further down the list. The `winning_name` on `ResolveHit` tells you which candidate fired; log it or stash it in your mod's telemetry so you can correlate a running session with a specific build of the game after the fact. The cascade also emits an Info-level log line of the form `"<label> resolved via '<name>' at 0x..."` the first time it succeeds, so you get build identification for free even without explicit caller logging.
366+
367+
## 7. Patch-proof patterns (cache, fallback, verify)
265368

266369
The raw Scanner API is intentionally low-level. Anything beyond a single call-site benefits from a thin layer above it. Below are patterns battle-tested in consumer projects.
267370

268-
### 6.1 Cache the `CompiledPattern`
371+
### 7.1 Cache the `CompiledPattern`
269372

270373
`parse_aob` is cheap but not free. If you scan repeatedly (hot-reload, re-scan after a level load, fallback between candidates), parse once and hold the `CompiledPattern` in a static or a class member:
271374

@@ -299,7 +402,7 @@ std::vector<CompiledCandidate> compile_all(std::span<const AobCandidate> raw)
299402
}
300403
```
301404
302-
### 6.2 Multi-candidate fallback
405+
### 7.2 Multi-candidate fallback
303406
304407
For a single logical hook, ship two or three signatures: one tight one for the current build, one wider one for the previous build, and a generic one as a safety net. Try them in order, stop on the first hit, log which one won.
305408
@@ -322,7 +425,7 @@ uintptr_t resolve_first_hit(
322425
}
323426
```
324427

325-
### 6.3 Verify after match
428+
### 7.3 Verify after match
326429

327430
A lone signature hit is necessary but not sufficient. Two lightweight checks catch the overwhelming majority of mis-hits:
328431

@@ -340,7 +443,7 @@ bool looks_like_prologue(const std::byte* addr)
340443
}
341444
```
342445
343-
### 6.4 Negative offsets
446+
### 7.4 Negative offsets
344447
345448
DMK's helpers assume you want to land *on* the match. Real-world hooks sometimes want to step backward from the match, for example to arrive at the function start after anchoring on a later landmark. Store a signed offset per candidate and apply it after the match succeeds:
346449
@@ -361,13 +464,13 @@ if (!hit) return 0;
361464
const auto* target = hit + candidate.disp_offset; // may walk backwards
362465
```
363466

364-
### 6.5 Name every candidate
467+
### 7.5 Name every candidate
365468

366469
Anonymous signatures make regressions unreadable. Attach a human-friendly label to every candidate (`"player_ctx_load_v1"`, `"fire_weapon_v2_backcompat"`). Log that label when a hit is found or when all candidates fail. It pays for itself the first time a patch breaks one of thirty signatures.
367470

368-
## 7. Worked examples
471+
## 8. Worked examples
369472

370-
### 7.1 Hook a direct `call rel32`
473+
### 8.1 Hook a direct `call rel32`
371474

372475
```cpp
373476
const auto pattern = sc::parse_aob("E8 ?? ?? ?? ?? 48 89 43 10");
@@ -386,7 +489,7 @@ hook_mgr.create_inline_hook("callee_hook", *target, &Detour_Callee,
386489

387490
If your pattern embeds a `|` marker, `find_pattern` has already applied `pattern->offset` to `hit`: pass `hit` directly to `resolve_rip_relative`. Adding `pattern->offset` again would double-apply and advance past the opcode.
388491

389-
### 7.2 Resolve a global pointer via `mov rax, [rip+disp32]`
492+
### 8.2 Resolve a global pointer via `mov rax, [rip+disp32]`
390493

391494
```cpp
392495
// Search 64 bytes from the match for the mov, then resolve.
@@ -406,7 +509,7 @@ auto global_ptr = dmk::Memory::read_ptr_unsafe(
406509

407510
If `hit` came from a pattern with a `|` offset marker, `find_pattern` has already applied the offset, so `hit` already points at the marked byte: pass it directly. Adding `pattern->offset` would double-apply and start the search window past the intended opcode.
408511

409-
### 7.3 Scan a packed binary
512+
### 8.3 Scan a packed binary
410513

411514
```cpp
412515
// Code decrypted into anonymous executable pages outside any loaded module.
@@ -419,7 +522,7 @@ if (!hit) return;
419522
// scan_executable_regions() already applied pattern->offset.
420523
```
421524

422-
### 7.4 Second occurrence with an offset marker
525+
### 8.4 Second occurrence with an offset marker
423526

424527
```cpp
425528
// "48 8B 88 B8 00 00 00 | 48 89 4C 24 68"
@@ -437,7 +540,7 @@ const auto* anchor = hit;
437540

438541
Reminder: both `find_pattern` overloads return the marked byte when a `|` marker is present (and the match start when it is absent). `pattern->offset` is applied for you; adding it manually double-applies.
439542

440-
## 8. DOs and DON'Ts
543+
## 9. DOs and DON'Ts
441544

442545
### DO
443546

@@ -462,7 +565,7 @@ Reminder: both `find_pattern` overloads return the marked byte when a `|` marker
462565
- **Don't** ignore a `PrefixNotFound` or `UnreadableDisplacement` error: they almost always mean the signature lost its context, not that the code simply moved.
463566
- **Don't** trust a single-build signature in a long-lived mod without a fallback.
464567

465-
## 9. Troubleshooting
568+
## 10. Troubleshooting
466569

467570
| Symptom | Likely cause | Remedy |
468571
| ------- | ------------ | ------ |
@@ -474,7 +577,7 @@ Reminder: both `find_pattern` overloads return the marked byte when a `|` marker
474577
| Works locally, fails on a different machine | Packer or anti-cheat transforming the module between load and scan | Switch to `scan_executable_regions`; add a later re-scan on first frame |
475578
| Multi-GB scan is slow | Patterns whose only literal bytes are common (`48 8B`, `E8`, etc.) | Broaden the anchor to include a rarer byte; the anchor selector prefers rarer bytes |
476579

477-
## 10. Further reading
580+
## 11. Further reading
478581

479582
- [C++ Core Guidelines - in-house coding standards](https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines)
480583
- [omni's hackpad: Fixing Hacks When a Game Gets Patched](https://badecho.com/index.php/2021/10/05/fixing-hacks-after-patch/)

include/DetourModKit.hpp

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,15 @@
2121
#include "DetourModKit/input.hpp"
2222

2323
// Module headers
24+
#include "DetourModKit/bootstrap.hpp"
2425
#include "DetourModKit/event_dispatcher.hpp"
2526
#include "DetourModKit/filesystem.hpp"
2627
#include "DetourModKit/format.hpp"
2728
#include "DetourModKit/math.hpp"
2829
#include "DetourModKit/memory.hpp"
2930
#include "DetourModKit/profiler.hpp"
3031
#include "DetourModKit/scanner.hpp"
32+
#include "DetourModKit/worker.hpp"
3133

3234
/**
3335
* @brief Convenient namespace aliases for common DetourModKit usage patterns.
@@ -42,6 +44,7 @@ namespace DMKFormat = DetourModKit::Format;
4244
namespace DMKFilesystem = DetourModKit::Filesystem;
4345
namespace DMKMemory = DetourModKit::Memory;
4446
namespace DMKMath = DetourModKit::Math;
47+
namespace DMKBootstrap = DetourModKit::Bootstrap;
4548

4649
#ifndef DMK_NO_SHORT_NAMES
4750
/**
@@ -50,6 +53,8 @@ namespace DMKMath = DetourModKit::Math;
5053
* Define DMK_NO_SHORT_NAMES before including this header to disable them.
5154
*/
5255
using DMKLogger = DetourModKit::Logger;
56+
using DMKStoppableWorker = DetourModKit::StoppableWorker;
57+
using DMKInputBindingGuard = DetourModKit::Config::InputBindingGuard;
5358
using DMKHookManager = DetourModKit::HookManager;
5459
using DMKLogLevel = DetourModKit::LogLevel;
5560
using DMKHookStatus = DetourModKit::HookStatus;

0 commit comments

Comments
 (0)