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
37 changes: 37 additions & 0 deletions .clang-format
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
# Formatting rules that codify DetourModKit's existing C++23 style.
# Derived from the current tree, not from any external/ submodule config.
Language: Cpp
BasedOnStyle: LLVM
# The project is C++23. clang-format has no explicit c++23 enum yet, so Latest
# selects the newest standard it knows and parses C++23 syntax correctly.
Standard: Latest
# The codebase does not enforce a hard column limit (some Doxygen and log lines
# run well past 120), so do not let the formatter reflow on width.
ColumnLimit: 0
IndentWidth: 4
TabWidth: 4
UseTab: Never
ContinuationIndentWidth: 4
AccessModifierOffset: -4
NamespaceIndentation: All
# Allman braces for functions, classes, and namespaces; K&R-style indented
# blocks inside function bodies.
BreakBeforeBraces: Allman
# East-side pointers and references (int *p), matching the whole tree. Do NOT
# switch to Left: the external/safetyhook config uses Left and must not be
# copied here.
PointerAlignment: Right
ReferenceAlignment: Right
# Keep trivial inline accessors on one line; everything else expands.
AllowShortFunctionsOnASingleLine: Inline
AllowShortBlocksOnASingleLine: Never
AllowShortIfStatementsOnASingleLine: false
AllowShortLoopsOnASingleLine: false
AllowShortCaseLabelsOnASingleLine: false
# Includes are grouped by hand (project, then external, then standard); the
# formatter must not reorder or regroup them.
SortIncludes: Never
IncludeBlocks: Preserve
SpaceAfterTemplateKeyword: true
FixNamespaceComments: true
22 changes: 22 additions & 0 deletions .clang-tidy
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Conservative, advisory static-analysis profile for DetourModKit.
# Start from nothing and opt into high-signal families. Win32, SEH, and
# low-level pointer idioms are intentional in this library, so the checks that
# flag them are disabled rather than worked around at the call site.
Checks: >
-*,
bugprone-*,
performance-*,
modernize-use-nullptr,
modernize-use-override,
modernize-use-equals-default,
modernize-use-using,
cppcoreguidelines-pro-type-member-init,
cppcoreguidelines-slicing,
-bugprone-easily-swappable-parameters,
-bugprone-reserved-identifier,
-bugprone-assignment-in-if-condition
# Advisory only: never fail a build on a tidy finding.
WarningsAsErrors: ''
# Only diagnose this project's own public headers, not external/ submodules.
HeaderFilterRegex: 'include/DetourModKit/.*\.hpp$'
FormatStyle: file
19 changes: 19 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# EditorConfig for DetourModKit (https://editorconfig.org).
# Line endings are intentionally left to git's normalization (.gitattributes /
# core.autocrlf), so this file does not set end_of_line.
root = true

[*]
charset = utf-8
indent_style = space
indent_size = 4
trim_trailing_whitespace = true
insert_final_newline = true

# Data and config files conventionally use two-space indentation.
[*.{yml,yaml,json}]
indent_size = 2

# Markdown uses trailing whitespace for hard line breaks.
[*.md]
trim_trailing_whitespace = false
69 changes: 69 additions & 0 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
name: Quality Probes

on:
pull_request:
branches: [main]
workflow_dispatch:

# Read-only token: this advisory workflow never writes to the repo.
permissions:
contents: read

# Both jobs are advisory and non-blocking. clang-format stays red until a planned
# repo-wide reformat; the sanitizer job runs on an MSYS2 toolchain that ships the
# ASan/UBSan runtimes (the runner's default MinGW does not). The blocking gate
# (build, tests, coverage) lives in pr-check.yml and is untouched here.

jobs:
format-check:
name: clang-format (advisory)
runs-on: windows-latest
continue-on-error: true
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false

- name: Install clang-format
run: pip install "clang-format==18.*"
shell: bash

- name: Check formatting (dry run, project sources only)
run: |
# git ls-files lists only this repo's tracked files, so submodule
# sources under external/ are never formatted.
git ls-files '*.cpp' '*.hpp' | xargs clang-format --dry-run --Werror
shell: bash

sanitizers:
name: ASan + UBSan probe (advisory)
runs-on: windows-latest
continue-on-error: true
defaults:
run:
shell: msys2 {0}
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
submodules: "recursive"
persist-credentials: false

- name: Set up MSYS2 MinGW toolchain (ships libasan/libubsan)
uses: msys2/setup-msys2@v2
with:
msystem: MINGW64
install: >-
mingw-w64-x86_64-gcc
mingw-w64-x86_64-cmake
mingw-w64-x86_64-ninja

- name: Configure (Debug + Sanitizers)
run: cmake --preset mingw-debug-asan

- name: Build
run: cmake --build --preset mingw-debug-asan --parallel

- name: Run tests under sanitizers
run: ctest --preset mingw-debug-asan
20 changes: 19 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,12 +98,26 @@ Memory validation-vs-direct-read numbers live in
# Dedicated sanitizer preset (ASan + UBSan)
cmake --preset mingw-debug-asan
cmake --build --preset mingw-debug-asan --parallel
ctest --preset mingw-debug-asan

# Or manually
# Dedicated coverage preset (gcov instrumentation)
cmake --preset mingw-debug-coverage
cmake --build --preset mingw-debug-coverage --parallel
ctest --preset mingw-debug-coverage

# Or enable either flag on top of mingw-debug manually
cmake --preset mingw-debug -DDMK_ENABLE_SANITIZERS=ON
cmake --preset mingw-debug -DDMK_ENABLE_COVERAGE=ON
```

The ASan/UBSan runtimes ship inside the MinGW GCC package itself
(`mingw-w64-x86_64-gcc` on MSYS2). If a sanitizer build fails to link with a
missing `libasan` / `libubsan`, reinstall or update that package
(`pacman -S mingw-w64-x86_64-gcc`). A non-blocking CI probe in
`.github/workflows/quality.yml` builds and runs the sanitizer preset (alongside
an advisory clang-format check) so regressions in that wiring surface without
gating PRs.

### Makefile wrapper

```bash
Expand All @@ -130,6 +144,7 @@ include/DetourModKit/ # Public headers -- one per module
memory.hpp # Memory read/write, sharded region cache, seh_read<T>, seh_resolve_chain/seh_read_chain<T>, plausible_userspace_ptr, PE module range
rtti.hpp # MSVC RTTI walker (type_name_of, vtable_is_type, find_in_pointer_table) + reverse name-to-vtable (vtable_for_type, vtables_for_type, TypeIdentity)
rtti_dissect.hpp # Reverse RTTI dissection + self-healing offsets (identify_pointee_type, reverse_scan_block, heal_landmark/heal_offset, solve_fingerprint) + drift-telemetry report (heal_report, DriftEntry)
drift_manifest.hpp # Durable serialize/parse of the self-heal drift report (DriftEntry) so drift can be saved and diffed across game versions
anchors.hpp # Declarative self-healing anchor registry over the vtable / cascade / code-constant / string-xref / manual backends, plus two-signal quorum corroboration and optional post-resolve validators (Anchors::resolve, resolve_all)
event_dispatcher.hpp # Typed pub/sub with RAII subscriptions (header-only)
profiler.hpp # Opt-in scoped timing (zero-cost when disabled)
Expand All @@ -139,6 +154,7 @@ include/DetourModKit/ # Public headers -- one per module
filesystem.hpp # Module directory resolution (wide-string and UTF-8 APIs)
bootstrap.hpp # DllMain lifecycle (worker thread, mutex, process gate)
worker.hpp # StoppableWorker RAII std::jthread wrapper
diagnostics.hpp # Consumer-queryable counters for intentional loader-lock leak/detach events, per subsystem
src/ # Implementation files. One .cpp per module, except a
# cohesive module may split into sibling TUs sharing one
# public header: scanner.cpp (scan engine) +
Expand Down Expand Up @@ -303,6 +319,8 @@ selection table, and the anti-patterns to remove -- lives in
- **Anchor registry tests:** `tests/test_anchors.cpp` covers `anchors.hpp` (`resolve`, `resolve_all`, `anchor_status_to_string`), one case per resolvable kind (Manual literal, CodeOperand, RipGlobal, VtableIdentity fail-closed) plus the `CallArgHome` `Unsupported` path and the parallel-report capacity behaviour. It also covers the optional post-resolve validator (accept, reject-fails-closed, context pass-through both ways, and the Manual / CallArgHome exemptions) and the `Quorum` kind (agreement, disagreement, one-signal-fails, null sub-anchor, rejected nesting, within-tolerance accept and reject, negative-tolerance fail-closed, the quorum's own validator applied to the corroborated value, and quorum propagation through `resolve_all`).
- **Test fixture pattern:** Each suite uses a `::testing::Test` subclass with `SetUp()`/`TearDown()` for temp file cleanup. Temp file paths must include the process ID (`_getpid()`) and a counter to avoid collisions when CTest runs tests in parallel as separate processes.
- **VMT hook test lifetime:** GoogleTest destroys test-body locals *before* calling `TearDown()`. VMT tests must explicitly call `remove_all_vmt_hooks()` (or `remove_vmt_hook`) before target objects go out of scope. Do not rely on `TearDown()` for VMT cleanup when the hooked object is a test-body local.
- **Drift manifest tests:** `tests/test_drift_manifest.cpp` covers `drift_manifest.hpp`: round-trip of a DriftEntry report through serialize/parse (including negative offsets and the owned-name-survives-source-destruction guarantee), the empty-report header-only case, file round-trip, and the fail-closed parse errors (missing header, wrong field count, non-numeric offset, missing file).
- **Diagnostics tests:** `tests/test_diagnostics.cpp` covers `diagnostics.hpp` (the per-subsystem intentional-leak counters): per-subsystem increment isolation, accumulation, the cross-subsystem total, the out-of-range `LeakSubsystem::Count` no-op, and reset. The instrumented loader-lock teardown sites themselves are not reachable from a normal test run; the counter contract is verified directly.
- **Coverage gate:** 80% minimum line coverage enforced in CI. All PRs must pass.
- **Concurrency tests:** Use `std::atomic<bool> stop` flag pattern with multiple threads. See `AsyncMode_ConcurrentLogAndDisable` in `test_logger.cpp` for the reference pattern.
- **Build flag:** Tests are enabled with `DMK_BUILD_TESTS=ON` (on by default in debug presets).
Expand Down
19 changes: 19 additions & 0 deletions CMakePresets.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@
"DMK_ENABLE_SANITIZERS": "ON"
}
},
{
"name": "mingw-debug-coverage",
"inherits": "mingw-debug",
"displayName": "MinGW Debug + Coverage",
"cacheVariables": {
"DMK_ENABLE_COVERAGE": "ON"
}
},
{
"name": "mingw-release",
"inherits": "base",
Expand Down Expand Up @@ -83,6 +91,10 @@
"name": "mingw-debug-asan",
"configurePreset": "mingw-debug-asan"
},
{
"name": "mingw-debug-coverage",
"configurePreset": "mingw-debug-coverage"
},
{
"name": "msvc-debug",
"configurePreset": "msvc-debug"
Expand All @@ -107,6 +119,13 @@
"outputOnFailure": true
}
},
{
"name": "mingw-debug-coverage",
"configurePreset": "mingw-debug-coverage",
"output": {
"outputOnFailure": true
}
},
{
"name": "msvc-debug",
"configurePreset": "msvc-debug",
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ DetourModKit is a full-featured C++23 toolkit designed to simplify common tasks
| 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` |
| 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; self-heal a field offset after a patch shifts the struct layout; rigid multi-field drift solver; drift-telemetry report | `rtti_dissect.hpp` |
| RTTI Self-Heal | Reverse-identify the object behind a pointer slot; 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 | `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 and optional post-resolve validators, resolved and reported in a single pass | `anchors.hpp` |
| Event Dispatcher | Typed pub/sub with RAII subscriptions | `event_dispatcher.hpp` |
| Profiler | Scoped timing with Chrome Tracing export (zero-cost when disabled) | `profiler.hpp` |
Expand All @@ -28,6 +28,7 @@ DetourModKit is a full-featured C++23 toolkit designed to simplify common tasks
| Version Macros | Compile-time version checking generated from CMake | `version.hpp` |
| Input System | Hotkey monitoring with background polling (keyboard/mouse/gamepad) | `input.hpp`, `input_codes.hpp` |
| Mod Bootstrap | DllMain scaffolding, instance mutex, process gate, lifecycle worker | `bootstrap.hpp` |
| Diagnostics | Consumer-queryable counters for intentional loader-lock leak/detach events, per subsystem | `diagnostics.hpp` |
| Stoppable Worker | RAII named `std::jthread` wrapper, loader-lock-safe teardown | `worker.hpp` |

<details>
Expand Down Expand Up @@ -61,6 +62,7 @@ DetourModKit is a full-featured C++23 toolkit designed to simplify common tasks
- Safe callback-based access to hooked methods via `with_vmt_method()`
- **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
- **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)
- **Batch toggling**: `enable_hooks` / `disable_hooks` (by name span) and `enable_all_hooks` / `disable_all_hooks` toggle many hooks under one lock acquisition for startup and hot-reload phases, returning the count affected (ergonomics, not a performance change: SafetyHook installs via a vectored exception handler and does not suspend threads)

</details>

Expand Down
4 changes: 4 additions & 0 deletions docs/misc/rtti-self-heal.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,10 @@ for (std::size_t i = 0; i < n; ++i)

Each entry carries `{name, nominal_offset, healed_offset, delta, ok, error}`; `delta` (`healed_offset - nominal_offset`) is the headline number. The landmarks must already have their `base` filled in, exactly as for a direct `heal_landmark` call.

### Persisting the report -- `drift_manifest.hpp`

`heal_report` produces the live report; `drift_manifest.hpp` makes it durable so two runs against two game versions can be diffed offline. `serialize_drift_report(entries)` renders a versioned, line-oriented manifest, `parse_drift_report` / `read_drift_report_from_file` read it back into owning `DriftRecord`s (the parsed records copy the name, so they outlive the source buffer), and `write_drift_report_to_file` saves it. Parsing fails closed (`ManifestError::MissingHeader` / `MalformedLine`). This is a report **archive for analysis**, not a heal input: nothing reads a manifest back to drive resolution, so it does not reintroduce the hand-edited-offset hazard the next paragraph warns about. The recipe (the `Landmark` set) still lives only in mod code.

A drift report is the signal that a patch moved a layout. When it shows a field the heal could not recover (`ok == false`, for example a type that was renamed across the patch and so no longer matches by name), that is a job for a mod update by someone who understands the engine, not something to paper over with a hand-edited offset: a wrong offset reads the wrong memory just as confidently as a right one. DetourModKit deliberately ships no persisted, user-editable heal file for that reason; the recipe (the `Landmark` set) lives in mod code.

## Performance and the init-time contract
Expand Down
73 changes: 73 additions & 0 deletions include/DetourModKit/diagnostics.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
#ifndef DETOURMODKIT_DIAGNOSTICS_HPP
#define DETOURMODKIT_DIAGNOSTICS_HPP

/**
* @file diagnostics.hpp
* @brief Consumer-queryable counters for DMK's intentional leak / detach paths.
*/

#include <cstddef>
#include <cstdint>

namespace DetourModKit
{
namespace Diagnostics
{
/**
* @enum LeakSubsystem
* @brief Identifies the subsystem that took an intentional leak / detach path.
* @details Each enumerator names one teardown site that deliberately leaks
* storage or detaches a thread instead of joining or freeing, to
* stay safe under the Windows loader lock (where a join or free
* would risk a deadlock or a use-after-unmap). These events fire at
* most once per subsystem per process and only on the loader-lock
* teardown path; they are not normal-shutdown counters.
*/
enum class LeakSubsystem : std::uint8_t
{
HookManager,
Logger,
AsyncLogger,
ConfigWatcher,
Input,
MemoryCache,
Worker,
Bootstrap,
Count ///< Sentinel: the number of tracked subsystems. Not a subsystem.
};

/**
* @brief Records that @p subsystem took an intentional leak / detach path.
* @details Performs a single relaxed atomic increment on a process-wide
* counter. Safe to call from a noexcept destructor and from
* DllMain / loader-lock context: it touches only a static atomic and
* never allocates, locks, or calls a Win32 API.
* @param subsystem The subsystem reporting the event. @ref LeakSubsystem::Count
* (or any out-of-range value) is ignored.
* @note Relaxed ordering is sufficient: the counter is an independent event
* tally with no happens-before relationship to other state.
*/
void record_intentional_leak(LeakSubsystem subsystem) noexcept;

/**
* @brief Returns how many intentional leak / detach events @p subsystem recorded.
* @param subsystem The subsystem to query.
* @return The event count, or 0 if @p subsystem is out of range.
*/
[[nodiscard]] std::size_t intentional_leak_count(LeakSubsystem subsystem) noexcept;

/**
* @brief Returns the total intentional leak / detach events across all subsystems.
* @return The summed event count.
*/
[[nodiscard]] std::size_t total_intentional_leaks() noexcept;

/**
* @brief Resets every subsystem counter to zero.
* @details Intended for test isolation; consumers normally only read.
*/
void reset_intentional_leaks() noexcept;
} // namespace Diagnostics
} // namespace DetourModKit

#endif // DETOURMODKIT_DIAGNOSTICS_HPP
Loading
Loading