diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..fcc35c4 --- /dev/null +++ b/.clang-format @@ -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 diff --git a/.clang-tidy b/.clang-tidy new file mode 100644 index 0000000..a33505d --- /dev/null +++ b/.clang-tidy @@ -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 diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..e6e2657 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..a78b3ee --- /dev/null +++ b/.github/workflows/quality.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 0bc8683..43026ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -130,6 +144,7 @@ include/DetourModKit/ # Public headers -- one per module memory.hpp # Memory read/write, sharded region cache, seh_read, seh_resolve_chain/seh_read_chain, 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) @@ -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) + @@ -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 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). diff --git a/CMakePresets.json b/CMakePresets.json index fcf448b..5573549 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -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", @@ -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" @@ -107,6 +119,13 @@ "outputOnFailure": true } }, + { + "name": "mingw-debug-coverage", + "configurePreset": "mingw-debug-coverage", + "output": { + "outputOnFailure": true + } + }, { "name": "msvc-debug", "configurePreset": "msvc-debug", diff --git a/README.md b/README.md index 3baf08a..0511c43 100644 --- a/README.md +++ b/README.md @@ -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` | @@ -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` |
@@ -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` 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)
diff --git a/docs/misc/rtti-self-heal.md b/docs/misc/rtti-self-heal.md index 67c600e..a31334a 100644 --- a/docs/misc/rtti-self-heal.md +++ b/docs/misc/rtti-self-heal.md @@ -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 diff --git a/include/DetourModKit/diagnostics.hpp b/include/DetourModKit/diagnostics.hpp new file mode 100644 index 0000000..89a3ec3 --- /dev/null +++ b/include/DetourModKit/diagnostics.hpp @@ -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 +#include + +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 diff --git a/include/DetourModKit/drift_manifest.hpp b/include/DetourModKit/drift_manifest.hpp new file mode 100644 index 0000000..ce829ab --- /dev/null +++ b/include/DetourModKit/drift_manifest.hpp @@ -0,0 +1,95 @@ +#ifndef DETOURMODKIT_DRIFT_MANIFEST_HPP +#define DETOURMODKIT_DRIFT_MANIFEST_HPP + +/** + * @file drift_manifest.hpp + * @brief Durable serialization of self-heal drift reports (@ref DetourModKit::Rtti::DriftEntry). + * @details Lets a consumer persist a @ref DetourModKit::Rtti::heal_report across game + * versions and diff the saved manifests to see which offsets moved between + * patches, instead of only logging the live telemetry once per run. + */ + +#include "DetourModKit/rtti_dissect.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace DetourModKit +{ + namespace Rtti + { + /** + * @struct DriftRecord + * @brief An owning, parsed drift entry read back from a manifest. + * @details The persisted counterpart to @ref DriftEntry. Unlike DriftEntry, + * whose @c name aliases caller storage, DriftRecord owns its name so + * it stays valid after the manifest text or file buffer is gone. + */ + struct DriftRecord + { + std::string name; ///< The landmark name (owned). + std::ptrdiff_t nominal_offset = 0; ///< Last-known offset recorded at write time. + std::ptrdiff_t healed_offset = 0; ///< Resolved offset (meaningful only when @ref ok). + std::ptrdiff_t delta = 0; ///< healed_offset - nominal_offset (meaningful only when @ref ok). + bool ok = false; ///< Whether the landmark healed. + HealError error{}; ///< Failure reason (meaningful only when @ref ok is false). + }; + + /** + * @enum ManifestError + * @brief Why parsing a drift manifest failed. Fails closed: no partial result. + */ + enum class ManifestError : std::uint8_t + { + MissingHeader, ///< The first non-blank line was not the manifest header. + MalformedLine ///< A record line had the wrong field count or an unparseable field. + }; + + /** + * @brief Serializes a drift report to a durable, line-oriented manifest. + * @details Emits a versioned header line followed by one tab-separated line + * per entry (name, nominal_offset, healed_offset, delta, ok, error). + * The error is written as a stable token, not the human-readable + * @ref heal_error_to_string text, so the manifest round-trips. Names + * are assumed free of tab and newline (MSVC mangled type names are). + * @param entries The drift entries to serialize (e.g. from @ref heal_report). + * @return The manifest text. + */ + [[nodiscard]] std::string serialize_drift_report(std::span entries); + + /** + * @brief Parses a drift manifest produced by @ref serialize_drift_report. + * @details Tolerates blank lines and trailing carriage returns (CRLF input). + * Fails closed on a missing header or any malformed record line. + * @param text The manifest text. + * @return The parsed records, or a @ref ManifestError. + */ + [[nodiscard]] std::expected, ManifestError> + parse_drift_report(std::string_view text); + + /** + * @brief Writes a drift report to a file via @ref serialize_drift_report. + * @param path Destination file path (UTF-8). + * @param entries The drift entries to serialize. + * @return true on success, false if the file could not be opened or written. + */ + [[nodiscard]] bool write_drift_report_to_file(const std::string &path, + std::span entries); + + /** + * @brief Reads and parses a drift manifest file. + * @param path Source file path (UTF-8). + * @return The parsed records, or a @ref ManifestError (MissingHeader is also + * returned when the file cannot be opened or is empty). + */ + [[nodiscard]] std::expected, ManifestError> + read_drift_report_from_file(const std::string &path); + } // namespace Rtti +} // namespace DetourModKit + +#endif // DETOURMODKIT_DRIFT_MANIFEST_HPP diff --git a/include/DetourModKit/hook_manager.hpp b/include/DetourModKit/hook_manager.hpp index 54047b0..fbf460c 100644 --- a/include/DetourModKit/hook_manager.hpp +++ b/include/DetourModKit/hook_manager.hpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -826,6 +827,44 @@ namespace DetourModKit */ [[nodiscard]] std::expected disable_hook(std::string_view hook_id); + /** + * @brief Enables several hooks by name in a single locked pass. + * @details Convenience for startup and hot-reload phases that toggle many + * hooks at once: takes the manager's locks once for the whole batch + * instead of once per hook. An unknown id is warned and skipped, not + * fatal, and an already-active hook counts as a success (enable is + * idempotent). This is an ergonomic wrapper, not a performance + * optimization over repeated enable_hook calls: the SafetyHook + * backend installs via a vectored exception handler and does not + * suspend threads, so there is no process-wide suspension to amortize. + * @param hook_ids The names of the hooks to enable. + * @return The number of hooks now active. + */ + [[nodiscard]] std::size_t enable_hooks(std::span hook_ids); + + /** + * @brief Disables several hooks by name in a single locked pass. + * @details The disable counterpart to @ref enable_hooks: locks once, warns + * and skips unknown ids, and counts an already-disabled hook as a + * success (disable is idempotent). Ergonomics only (see + * @ref enable_hooks). + * @param hook_ids The names of the hooks to disable. + * @return The number of hooks now disabled. + */ + [[nodiscard]] std::size_t disable_hooks(std::span hook_ids); + + /** + * @brief Enables every hook currently managed by this instance in one pass. + * @return The number of hooks now active. + */ + [[nodiscard]] std::size_t enable_all_hooks(); + + /** + * @brief Disables every hook currently managed by this instance in one pass. + * @return The number of hooks now disabled. + */ + [[nodiscard]] std::size_t disable_all_hooks(); + /** * @brief Retrieves the current status of a hook. * @param hook_id The name of the hook. @@ -1097,6 +1136,19 @@ namespace DetourModKit std::string error_to_string(const safetyhook::InlineHook::Error &err) const; std::string error_to_string(const safetyhook::MidHook::Error &err) const; + /** + * @brief Enables or disables one already-located hook under the held locks. + * @details Shared body of the batch toggle methods. The caller must hold + * m_mutator_gate and m_hooks_mutex (both shared) and must have + * confirmed the manager is not shutting down. Logs the outcome + * exactly like the single-hook enable_hook / disable_hook path. + * @param hook_id The hook's name, used only for logging. + * @param hook The hook to toggle. + * @param enable true to enable, false to disable. + * @return true if the hook is now in the requested state. + */ + [[nodiscard]] bool toggle_hook_locked(std::string_view hook_id, Hook &hook, bool enable); + bool hook_id_exists_locked(std::string_view hook_id) const { return m_hooks.find(hook_id) != m_hooks.end(); diff --git a/src/async_logger.cpp b/src/async_logger.cpp index 9af0c52..7c26592 100644 --- a/src/async_logger.cpp +++ b/src/async_logger.cpp @@ -1,4 +1,5 @@ #include "DetourModKit/async_logger.hpp" +#include "DetourModKit/diagnostics.hpp" #include "platform.hpp" #include @@ -538,6 +539,7 @@ namespace DetourModKit { pin_current_module(); m_writer_thread.detach(); + DetourModKit::Diagnostics::record_intentional_leak(DetourModKit::Diagnostics::LeakSubsystem::AsyncLogger); } else { diff --git a/src/bootstrap.cpp b/src/bootstrap.cpp index 8fdeec9..8baae97 100644 --- a/src/bootstrap.cpp +++ b/src/bootstrap.cpp @@ -1,4 +1,5 @@ #include "DetourModKit/bootstrap.hpp" +#include "DetourModKit/diagnostics.hpp" #include "DetourModKit/config.hpp" #include "DetourModKit/hook_manager.hpp" @@ -311,6 +312,7 @@ namespace DetourModKit::Bootstrap // that callers who need a clean unload call request_shutdown() // before FreeLibrary and give the worker time to drain. SetEvent(g_shutdown_event); + DetourModKit::Diagnostics::record_intentional_leak(DetourModKit::Diagnostics::LeakSubsystem::Bootstrap); } if (g_shutdown_event) diff --git a/src/config.cpp b/src/config.cpp index 5d5e225..0b0e5a0 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -39,8 +39,8 @@ #include using namespace DetourModKit; -using namespace DetourModKit::Filesystem; -using namespace DetourModKit::String; +using DetourModKit::Filesystem::get_runtime_directory; +using DetourModKit::String::trim; // Anonymous namespace for internal helpers and storage namespace diff --git a/src/config_watcher.cpp b/src/config_watcher.cpp index d5e1ae0..68f45f3 100644 --- a/src/config_watcher.cpp +++ b/src/config_watcher.cpp @@ -4,6 +4,7 @@ */ #include "DetourModKit/config_watcher.hpp" +#include "DetourModKit/diagnostics.hpp" #include "DetourModKit/logger.hpp" #include "DetourModKit/worker.hpp" @@ -241,6 +242,7 @@ namespace DetourModKit { static_cast(m_impl.release()); } + DetourModKit::Diagnostics::record_intentional_leak(DetourModKit::Diagnostics::LeakSubsystem::ConfigWatcher); return; } diff --git a/src/diagnostics.cpp b/src/diagnostics.cpp new file mode 100644 index 0000000..53642c8 --- /dev/null +++ b/src/diagnostics.cpp @@ -0,0 +1,65 @@ +/** + * @file diagnostics.cpp + * @brief Process-wide counters for DMK's intentional leak / detach paths. + */ + +#include "DetourModKit/diagnostics.hpp" + +#include +#include +#include + +namespace DetourModKit +{ + namespace Diagnostics + { + namespace + { + constexpr std::size_t LEAK_SUBSYSTEM_COUNT = static_cast(LeakSubsystem::Count); + + // One independent event tally per subsystem. Relaxed throughout: the + // counters carry no ordering obligation toward any other state, and each + // leak site fires at most once per process, so there is no meaningful + // contention to order. + std::array, LEAK_SUBSYSTEM_COUNT> s_leak_counts{}; + } // namespace + + void record_intentional_leak(LeakSubsystem subsystem) noexcept + { + const auto index = static_cast(subsystem); + if (index >= LEAK_SUBSYSTEM_COUNT) + { + return; + } + s_leak_counts[index].fetch_add(1, std::memory_order_relaxed); + } + + std::size_t intentional_leak_count(LeakSubsystem subsystem) noexcept + { + const auto index = static_cast(subsystem); + if (index >= LEAK_SUBSYSTEM_COUNT) + { + return 0; + } + return s_leak_counts[index].load(std::memory_order_relaxed); + } + + std::size_t total_intentional_leaks() noexcept + { + std::size_t total = 0; + for (const auto &counter : s_leak_counts) + { + total += counter.load(std::memory_order_relaxed); + } + return total; + } + + void reset_intentional_leaks() noexcept + { + for (auto &counter : s_leak_counts) + { + counter.store(0, std::memory_order_relaxed); + } + } + } // namespace Diagnostics +} // namespace DetourModKit diff --git a/src/drift_manifest.cpp b/src/drift_manifest.cpp new file mode 100644 index 0000000..70c7757 --- /dev/null +++ b/src/drift_manifest.cpp @@ -0,0 +1,221 @@ +/** + * @file drift_manifest.cpp + * @brief Durable serialization of self-heal drift reports. + */ + +#include "DetourModKit/drift_manifest.hpp" + +#include +#include +#include +#include + +namespace DetourModKit +{ + namespace Rtti + { + namespace + { + constexpr std::string_view MANIFEST_HEADER = "# DetourModKit drift manifest v1"; + constexpr char FIELD_SEP = '\t'; + + // Stable round-trip tokens for HealError, deliberately distinct from the + // verbose human-readable heal_error_to_string text (which is for logs): + // a manifest must parse back even if the log wording is reworded. + [[nodiscard]] std::string_view heal_error_token(HealError error) noexcept + { + switch (error) + { + case HealError::BadDescriptor: + return "BadDescriptor"; + case HealError::NoMatch: + return "NoMatch"; + case HealError::Ambiguous: + return "Ambiguous"; + } + return "BadDescriptor"; + } + + [[nodiscard]] bool parse_heal_error(std::string_view token, HealError &out) noexcept + { + if (token == "BadDescriptor") + { + out = HealError::BadDescriptor; + return true; + } + if (token == "NoMatch") + { + out = HealError::NoMatch; + return true; + } + if (token == "Ambiguous") + { + out = HealError::Ambiguous; + return true; + } + return false; + } + + // Parses a decimal (possibly negative) offset that must span the whole field. + [[nodiscard]] bool parse_offset(std::string_view field, std::ptrdiff_t &out) noexcept + { + if (field.empty()) + { + return false; + } + const char *const begin = field.data(); + const char *const end = field.data() + field.size(); + const auto result = std::from_chars(begin, end, out); + return result.ec == std::errc{} && result.ptr == end; + } + } // namespace + + std::string serialize_drift_report(std::span entries) + { + std::string out; + out.append(MANIFEST_HEADER.data(), MANIFEST_HEADER.size()); + out.push_back('\n'); + for (const DriftEntry &entry : entries) + { + out.append(entry.name.data(), entry.name.size()); + out.push_back(FIELD_SEP); + out.append(std::to_string(entry.nominal_offset)); + out.push_back(FIELD_SEP); + out.append(std::to_string(entry.healed_offset)); + out.push_back(FIELD_SEP); + out.append(std::to_string(entry.delta)); + out.push_back(FIELD_SEP); + out.push_back(entry.ok ? '1' : '0'); + out.push_back(FIELD_SEP); + const std::string_view token = heal_error_token(entry.error); + out.append(token.data(), token.size()); + out.push_back('\n'); + } + return out; + } + + std::expected, ManifestError> + parse_drift_report(std::string_view text) + { + std::vector records; + bool header_seen = false; + std::size_t pos = 0; + while (pos <= text.size()) + { + const std::size_t newline = text.find('\n', pos); + std::string_view line = (newline == std::string_view::npos) + ? text.substr(pos) + : text.substr(pos, newline - pos); + pos = (newline == std::string_view::npos) ? text.size() + 1 : newline + 1; + + // Strip a trailing CR (CRLF input) and skip blank lines. + if (!line.empty() && line.back() == '\r') + { + line.remove_suffix(1); + } + if (line.empty()) + { + continue; + } + + if (!header_seen) + { + if (line != MANIFEST_HEADER) + { + return std::unexpected(ManifestError::MissingHeader); + } + header_seen = true; + continue; + } + + // Split into exactly six tab-separated fields; any other count is + // malformed. + std::string_view fields[6]; + std::size_t field_count = 0; + std::size_t field_pos = 0; + bool too_many = false; + while (true) + { + const std::size_t sep = line.find(FIELD_SEP, field_pos); + const std::string_view field = (sep == std::string_view::npos) + ? line.substr(field_pos) + : line.substr(field_pos, sep - field_pos); + if (field_count >= 6) + { + too_many = true; + break; + } + fields[field_count++] = field; + if (sep == std::string_view::npos) + { + break; + } + field_pos = sep + 1; + } + if (too_many || field_count != 6) + { + return std::unexpected(ManifestError::MalformedLine); + } + + DriftRecord record; + record.name = std::string(fields[0]); + if (!parse_offset(fields[1], record.nominal_offset) || + !parse_offset(fields[2], record.healed_offset) || + !parse_offset(fields[3], record.delta)) + { + return std::unexpected(ManifestError::MalformedLine); + } + if (fields[4] == "1") + { + record.ok = true; + } + else if (fields[4] == "0") + { + record.ok = false; + } + else + { + return std::unexpected(ManifestError::MalformedLine); + } + if (!parse_heal_error(fields[5], record.error)) + { + return std::unexpected(ManifestError::MalformedLine); + } + records.push_back(std::move(record)); + } + + if (!header_seen) + { + return std::unexpected(ManifestError::MissingHeader); + } + return records; + } + + bool write_drift_report_to_file(const std::string &path, std::span entries) + { + // Binary mode so the '\n' line endings written here are not translated to + // CRLF; the parser tolerates either, but a stable on-disk form is clearer. + std::ofstream file(path, std::ios::binary | std::ios::trunc); + if (!file) + { + return false; + } + const std::string text = serialize_drift_report(entries); + file.write(text.data(), static_cast(text.size())); + return static_cast(file); + } + + std::expected, ManifestError> + read_drift_report_from_file(const std::string &path) + { + std::ifstream file(path, std::ios::binary); + if (!file) + { + return std::unexpected(ManifestError::MissingHeader); + } + const std::string text((std::istreambuf_iterator(file)), + std::istreambuf_iterator()); + return parse_drift_report(text); + } + } // namespace Rtti +} // namespace DetourModKit diff --git a/src/hook_manager.cpp b/src/hook_manager.cpp index 965614a..b8a243e 100644 --- a/src/hook_manager.cpp +++ b/src/hook_manager.cpp @@ -1,4 +1,5 @@ #include "DetourModKit/hook_manager.hpp" +#include "DetourModKit/diagnostics.hpp" #include "DetourModKit/format.hpp" #include "DetourModKit/memory.hpp" #include "platform.hpp" @@ -17,7 +18,7 @@ #include using namespace DetourModKit; -using namespace DetourModKit::Scanner; +using DetourModKit::Scanner::parse_aob; namespace { @@ -200,6 +201,9 @@ HookManager::~HookManager() noexcept { leaked_vmt_hooks->swap(m_vmt_hooks); } + // Surface this loader-lock leak so consumers can observe teardown + // escape-hatch activity without parsing logs. + DetourModKit::Diagnostics::record_intentional_leak(DetourModKit::Diagnostics::LeakSubsystem::HookManager); m_shutdown_called.store(true, std::memory_order_release); return; } @@ -843,6 +847,149 @@ std::expected HookManager::disable_hook(std::string_view hook_i return std::unexpected(error); } +bool HookManager::toggle_hook_locked(std::string_view hook_id, Hook &hook, bool enable) +{ + auto result = enable ? hook.enable() : hook.disable(); + // "enable" + 'd' / "disable" + 'd' reads as "enabled" / "disabled" in the log. + const std::string_view verb = enable ? "enable" : "disable"; + if (result) + { + m_logger.debug("HookManager: Hook '{}' successfully {}d.", hook_id, verb); + return true; + } + + const auto error = result.error(); + if (error == HookError::InvalidHookState) + { + m_logger.warning("HookManager: Hook '{}' cannot be {}d. Current status: {}", hook_id, verb, Hook::status_to_string(hook.get_status())); + } + else + { + m_logger.error("HookManager: Failed to {} hook '{}': {}", verb, hook_id, Hook::error_to_string(error)); + } + return false; +} + +std::size_t HookManager::enable_hooks(std::span hook_ids) +{ + if (m_shutdown_called.load(std::memory_order_acquire)) + { + m_logger.warning("HookManager: Shutdown in progress. Cannot enable {} hook(s).", hook_ids.size()); + return 0; + } + + std::shared_lock mutator_gate(m_mutator_gate); + std::shared_lock lock(m_hooks_mutex); + if (m_shutdown_called.load(std::memory_order_acquire)) + { + m_logger.warning("HookManager: Shutdown in progress. Cannot enable {} hook(s).", hook_ids.size()); + return 0; + } + + std::size_t enabled = 0; + for (const std::string_view hook_id : hook_ids) + { + auto it = m_hooks.find(hook_id); + if (it == m_hooks.end()) + { + m_logger.warning("HookManager: Hook ID '{}' not found for enable operation.", hook_id); + continue; + } + if (toggle_hook_locked(hook_id, *it->second, true)) + { + ++enabled; + } + } + return enabled; +} + +std::size_t HookManager::disable_hooks(std::span hook_ids) +{ + if (m_shutdown_called.load(std::memory_order_acquire)) + { + m_logger.warning("HookManager: Shutdown in progress. Cannot disable {} hook(s).", hook_ids.size()); + return 0; + } + + std::shared_lock mutator_gate(m_mutator_gate); + std::shared_lock lock(m_hooks_mutex); + if (m_shutdown_called.load(std::memory_order_acquire)) + { + m_logger.warning("HookManager: Shutdown in progress. Cannot disable {} hook(s).", hook_ids.size()); + return 0; + } + + std::size_t disabled = 0; + for (const std::string_view hook_id : hook_ids) + { + auto it = m_hooks.find(hook_id); + if (it == m_hooks.end()) + { + m_logger.warning("HookManager: Hook ID '{}' not found for disable operation.", hook_id); + continue; + } + if (toggle_hook_locked(hook_id, *it->second, false)) + { + ++disabled; + } + } + return disabled; +} + +std::size_t HookManager::enable_all_hooks() +{ + if (m_shutdown_called.load(std::memory_order_acquire)) + { + m_logger.warning("HookManager: Shutdown in progress. Cannot enable all hooks."); + return 0; + } + + std::shared_lock mutator_gate(m_mutator_gate); + std::shared_lock lock(m_hooks_mutex); + if (m_shutdown_called.load(std::memory_order_acquire)) + { + m_logger.warning("HookManager: Shutdown in progress. Cannot enable all hooks."); + return 0; + } + + std::size_t enabled = 0; + for (const auto &[hook_id, hook] : m_hooks) + { + if (toggle_hook_locked(hook_id, *hook, true)) + { + ++enabled; + } + } + return enabled; +} + +std::size_t HookManager::disable_all_hooks() +{ + if (m_shutdown_called.load(std::memory_order_acquire)) + { + m_logger.warning("HookManager: Shutdown in progress. Cannot disable all hooks."); + return 0; + } + + std::shared_lock mutator_gate(m_mutator_gate); + std::shared_lock lock(m_hooks_mutex); + if (m_shutdown_called.load(std::memory_order_acquire)) + { + m_logger.warning("HookManager: Shutdown in progress. Cannot disable all hooks."); + return 0; + } + + std::size_t disabled = 0; + for (const auto &[hook_id, hook] : m_hooks) + { + if (toggle_hook_locked(hook_id, *hook, false)) + { + ++disabled; + } + } + return disabled; +} + std::optional HookManager::get_hook_status(std::string_view hook_id) const { std::shared_lock lock(m_hooks_mutex); diff --git a/src/input.cpp b/src/input.cpp index 5ab6737..87b4b64 100644 --- a/src/input.cpp +++ b/src/input.cpp @@ -9,6 +9,7 @@ */ #include "DetourModKit/input.hpp" +#include "DetourModKit/diagnostics.hpp" #include "DetourModKit/config.hpp" #include "DetourModKit/logger.hpp" @@ -506,6 +507,7 @@ namespace DetourModKit // DllMain mutex -- would deadlock). Mirrors clear_bindings(invoke_callbacks=false). pin_current_module(); m_poll_thread.detach(); + DetourModKit::Diagnostics::record_intentional_leak(DetourModKit::Diagnostics::LeakSubsystem::Input); m_running.store(false, std::memory_order_release); return; } diff --git a/src/logger.cpp b/src/logger.cpp index 39c42e7..d46e24a 100644 --- a/src/logger.cpp +++ b/src/logger.cpp @@ -1,4 +1,5 @@ #include "DetourModKit/logger.hpp" +#include "DetourModKit/diagnostics.hpp" #include "DetourModKit/async_logger.hpp" #include "DetourModKit/filesystem.hpp" #include "DetourModKit/format.hpp" @@ -230,6 +231,7 @@ namespace DetourModKit auto *leaked = new (std::nothrow) std::shared_ptr(std::move(local_logger)); static_cast(leaked); + DetourModKit::Diagnostics::record_intentional_leak(DetourModKit::Diagnostics::LeakSubsystem::Logger); } { diff --git a/src/memory.cpp b/src/memory.cpp index 84235ad..ff56b93 100644 --- a/src/memory.cpp +++ b/src/memory.cpp @@ -12,6 +12,7 @@ */ #include "DetourModKit/memory.hpp" +#include "DetourModKit/diagnostics.hpp" #include "DetourModKit/format.hpp" #include "DetourModKit/logger.hpp" #include "platform.hpp" @@ -894,6 +895,7 @@ bool DetourModKit::Memory::init_cache(size_t cache_size, unsigned int expiry_ms, { pin_current_module(); s_cleanupThread.detach(); + DetourModKit::Diagnostics::record_intentional_leak(DetourModKit::Diagnostics::LeakSubsystem::MemoryCache); } s_cacheInitialized.store(false, std::memory_order_release); return; @@ -969,6 +971,7 @@ void DetourModKit::Memory::shutdown_cache() // flag and exit on its own. pin_current_module(); s_cleanupThread.detach(); + DetourModKit::Diagnostics::record_intentional_leak(DetourModKit::Diagnostics::LeakSubsystem::MemoryCache); } else { diff --git a/src/scanner.cpp b/src/scanner.cpp index 8fe7634..8cd46df 100644 --- a/src/scanner.cpp +++ b/src/scanner.cpp @@ -44,7 +44,6 @@ #endif using namespace DetourModKit; -using namespace DetourModKit::String; namespace { diff --git a/src/worker.cpp b/src/worker.cpp index 6a0fd92..03d2fce 100644 --- a/src/worker.cpp +++ b/src/worker.cpp @@ -1,4 +1,5 @@ #include "DetourModKit/worker.hpp" +#include "DetourModKit/diagnostics.hpp" #include "DetourModKit/logger.hpp" #include "platform.hpp" @@ -78,6 +79,7 @@ namespace DetourModKit { detail::pin_current_module(); m_thread.detach(); + DetourModKit::Diagnostics::record_intentional_leak(DetourModKit::Diagnostics::LeakSubsystem::Worker); return; } diff --git a/tests/test_diagnostics.cpp b/tests/test_diagnostics.cpp new file mode 100644 index 0000000..9afd54a --- /dev/null +++ b/tests/test_diagnostics.cpp @@ -0,0 +1,73 @@ +#include + +#include "DetourModKit/diagnostics.hpp" + +using DetourModKit::Diagnostics::LeakSubsystem; +namespace diag = DetourModKit::Diagnostics; + +// The counters are process-global. ctest runs each test in its own process, and +// the instrumented loader-lock paths never fire under a normal test run, so a +// reset in SetUp gives each case a clean, deterministic starting point. +class DiagnosticsTest : public ::testing::Test +{ +protected: + void SetUp() override + { + diag::reset_intentional_leaks(); + } + + void TearDown() override + { + diag::reset_intentional_leaks(); + } +}; + +TEST_F(DiagnosticsTest, StartsZeroAfterReset) +{ + EXPECT_EQ(diag::total_intentional_leaks(), 0u); + EXPECT_EQ(diag::intentional_leak_count(LeakSubsystem::HookManager), 0u); +} + +TEST_F(DiagnosticsTest, RecordIncrementsOnlyTheNamedSubsystem) +{ + diag::record_intentional_leak(LeakSubsystem::Logger); + EXPECT_EQ(diag::intentional_leak_count(LeakSubsystem::Logger), 1u); + EXPECT_EQ(diag::intentional_leak_count(LeakSubsystem::HookManager), 0u); + EXPECT_EQ(diag::total_intentional_leaks(), 1u); +} + +TEST_F(DiagnosticsTest, RecordAccumulates) +{ + diag::record_intentional_leak(LeakSubsystem::Worker); + diag::record_intentional_leak(LeakSubsystem::Worker); + diag::record_intentional_leak(LeakSubsystem::Worker); + EXPECT_EQ(diag::intentional_leak_count(LeakSubsystem::Worker), 3u); + EXPECT_EQ(diag::total_intentional_leaks(), 3u); +} + +TEST_F(DiagnosticsTest, TotalSumsAcrossSubsystems) +{ + diag::record_intentional_leak(LeakSubsystem::HookManager); + diag::record_intentional_leak(LeakSubsystem::Logger); + diag::record_intentional_leak(LeakSubsystem::MemoryCache); + EXPECT_EQ(diag::total_intentional_leaks(), 3u); +} + +TEST_F(DiagnosticsTest, OutOfRangeSubsystemIsIgnored) +{ + // The Count sentinel (and any value at or beyond it) must be a no-op, never an + // out-of-bounds write into the counter array. + diag::record_intentional_leak(LeakSubsystem::Count); + EXPECT_EQ(diag::total_intentional_leaks(), 0u); + EXPECT_EQ(diag::intentional_leak_count(LeakSubsystem::Count), 0u); +} + +TEST_F(DiagnosticsTest, ResetZeroesEverySubsystem) +{ + diag::record_intentional_leak(LeakSubsystem::Input); + diag::record_intentional_leak(LeakSubsystem::Bootstrap); + diag::reset_intentional_leaks(); + EXPECT_EQ(diag::total_intentional_leaks(), 0u); + EXPECT_EQ(diag::intentional_leak_count(LeakSubsystem::Input), 0u); + EXPECT_EQ(diag::intentional_leak_count(LeakSubsystem::Bootstrap), 0u); +} diff --git a/tests/test_drift_manifest.cpp b/tests/test_drift_manifest.cpp new file mode 100644 index 0000000..f63f451 --- /dev/null +++ b/tests/test_drift_manifest.cpp @@ -0,0 +1,149 @@ +#include + +#include +#include +#include + +#include "DetourModKit/drift_manifest.hpp" +#include "DetourModKit/rtti_dissect.hpp" + +#include // _getpid for collision-free temp paths under parallel CTest + +using DetourModKit::Rtti::DriftEntry; +using DetourModKit::Rtti::HealError; +using DetourModKit::Rtti::ManifestError; +namespace rtti = DetourModKit::Rtti; + +TEST(DriftManifestTest, RoundTripPreservesEntries) +{ + const std::string name_a = ".?AVFoo@@"; + const std::string name_b = ".?AVBar@@"; + DriftEntry entries[2]; + entries[0].name = name_a; + entries[0].nominal_offset = 0x10; + entries[0].healed_offset = 0x18; + entries[0].delta = 0x8; + entries[0].ok = true; + entries[1].name = name_b; + entries[1].nominal_offset = 0x40; + entries[1].ok = false; + entries[1].error = HealError::NoMatch; + + const auto parsed = rtti::parse_drift_report(rtti::serialize_drift_report(entries)); + ASSERT_TRUE(parsed.has_value()); + ASSERT_EQ(parsed->size(), 2u); + + EXPECT_EQ((*parsed)[0].name, name_a); + EXPECT_EQ((*parsed)[0].nominal_offset, 0x10); + EXPECT_EQ((*parsed)[0].healed_offset, 0x18); + EXPECT_EQ((*parsed)[0].delta, 0x8); + EXPECT_TRUE((*parsed)[0].ok); + + EXPECT_EQ((*parsed)[1].name, name_b); + EXPECT_FALSE((*parsed)[1].ok); + EXPECT_EQ((*parsed)[1].error, HealError::NoMatch); +} + +TEST(DriftManifestTest, NameSurvivesSourceDestruction) +{ + std::string text; + { + // DriftEntry.name aliases this buffer; it goes out of scope before parse. + const std::string transient_name = ".?AVTransient@@"; + DriftEntry entry; + entry.name = transient_name; + entry.nominal_offset = 4; + text = rtti::serialize_drift_report(std::span(&entry, 1)); + } + const auto parsed = rtti::parse_drift_report(text); + ASSERT_TRUE(parsed.has_value()); + ASSERT_EQ(parsed->size(), 1u); + // The parsed record owns its name, so it stays valid after the source is gone. + EXPECT_EQ((*parsed)[0].name, ".?AVTransient@@"); +} + +TEST(DriftManifestTest, NegativeOffsetsRoundTrip) +{ + DriftEntry entry; + entry.name = "neg"; + entry.nominal_offset = -16; + entry.healed_offset = -8; + entry.delta = 8; + entry.ok = true; + const auto parsed = + rtti::parse_drift_report(rtti::serialize_drift_report(std::span(&entry, 1))); + ASSERT_TRUE(parsed.has_value()); + ASSERT_EQ(parsed->size(), 1u); + EXPECT_EQ((*parsed)[0].nominal_offset, -16); + EXPECT_EQ((*parsed)[0].healed_offset, -8); + EXPECT_EQ((*parsed)[0].delta, 8); +} + +TEST(DriftManifestTest, EmptyReportHasHeaderOnlyAndParsesEmpty) +{ + const auto parsed = rtti::parse_drift_report(rtti::serialize_drift_report({})); + ASSERT_TRUE(parsed.has_value()); + EXPECT_TRUE(parsed->empty()); +} + +TEST(DriftManifestTest, ParseRejectsMissingHeader) +{ + const auto parsed = rtti::parse_drift_report("not a header\nfoo\t1\t2\t3\t1\tNoMatch\n"); + ASSERT_FALSE(parsed.has_value()); + EXPECT_EQ(parsed.error(), ManifestError::MissingHeader); +} + +TEST(DriftManifestTest, ParseRejectsMalformedLine) +{ + // Header present, but a record line with too few fields. + const auto parsed = rtti::parse_drift_report("# DetourModKit drift manifest v1\nfoo\t1\t2\n"); + ASSERT_FALSE(parsed.has_value()); + EXPECT_EQ(parsed.error(), ManifestError::MalformedLine); +} + +TEST(DriftManifestTest, ParseRejectsNonNumericOffset) +{ + const auto parsed = + rtti::parse_drift_report("# DetourModKit drift manifest v1\nfoo\tNaN\t2\t3\t1\tNoMatch\n"); + ASSERT_FALSE(parsed.has_value()); + EXPECT_EQ(parsed.error(), ManifestError::MalformedLine); +} + +TEST(DriftManifestTest, ParseToleratesBlankLinesAndCrlf) +{ + const std::string text = + "# DetourModKit drift manifest v1\r\n\r\nfoo\t1\t2\t1\t1\tBadDescriptor\r\n\r\n"; + const auto parsed = rtti::parse_drift_report(text); + ASSERT_TRUE(parsed.has_value()); + ASSERT_EQ(parsed->size(), 1u); + EXPECT_EQ((*parsed)[0].name, "foo"); + EXPECT_EQ((*parsed)[0].delta, 1); +} + +TEST(DriftManifestTest, FileRoundTrip) +{ + DriftEntry entry; + entry.name = ".?AVFileFoo@@"; + entry.nominal_offset = 0x20; + entry.healed_offset = 0x28; + entry.delta = 8; + entry.ok = true; + + const std::string path = + std::string("dmk_drift_manifest_test_") + std::to_string(_getpid()) + ".tmp"; + ASSERT_TRUE(rtti::write_drift_report_to_file(path, std::span(&entry, 1))); + const auto parsed = rtti::read_drift_report_from_file(path); + std::remove(path.c_str()); + + ASSERT_TRUE(parsed.has_value()); + ASSERT_EQ(parsed->size(), 1u); + EXPECT_EQ((*parsed)[0].name, ".?AVFileFoo@@"); + EXPECT_EQ((*parsed)[0].healed_offset, 0x28); +} + +TEST(DriftManifestTest, ReadMissingFileFailsClosed) +{ + const auto parsed = rtti::read_drift_report_from_file("dmk_definitely_no_such_manifest.tmp"); + ASSERT_FALSE(parsed.has_value()); + EXPECT_EQ(parsed.error(), ManifestError::MissingHeader); +} diff --git a/tests/test_hook_manager.cpp b/tests/test_hook_manager.cpp index 3d92f57..06903b5 100644 --- a/tests/test_hook_manager.cpp +++ b/tests/test_hook_manager.cpp @@ -10,7 +10,9 @@ #include #include #include +#include #include +#include #include #include "DetourModKit/hook_manager.hpp" @@ -490,6 +492,81 @@ TEST_F(HookManagerTest, RealInlineHook_EnableDisable) EXPECT_EQ(*hook_manager_->get_hook_status("RealEnDisHook"), HookStatus::Active); } +// Creates two real inline hooks on the two distinct test targets; returns their +// ids. Each TEST_F gets a fresh manager (SetUp/TearDown call remove_all_hooks). +static std::vector make_two_real_hooks(HookManager &manager) +{ + void *tramp_add = nullptr; + void *tramp_mul = nullptr; + EXPECT_TRUE(manager.create_inline_hook("BatchHookAdd", + reinterpret_cast(&real_hook_target_add), + reinterpret_cast(&real_hook_detour_add), &tramp_add) + .has_value()); + EXPECT_TRUE(manager.create_inline_hook("BatchHookMul", + reinterpret_cast(&real_hook_target_mul), + reinterpret_cast(&real_hook_detour_mul), &tramp_mul) + .has_value()); + return {"BatchHookAdd", "BatchHookMul"}; +} + +TEST_F(HookManagerTest, BatchDisableThenEnable_RealHooks) +{ + const auto ids = make_two_real_hooks(*hook_manager_); + const std::vector id_views{ids[0], ids[1]}; + + EXPECT_EQ(hook_manager_->disable_hooks(id_views), 2u); + EXPECT_EQ(*hook_manager_->get_hook_status("BatchHookAdd"), HookStatus::Disabled); + EXPECT_EQ(*hook_manager_->get_hook_status("BatchHookMul"), HookStatus::Disabled); + + EXPECT_EQ(hook_manager_->enable_hooks(id_views), 2u); + EXPECT_EQ(*hook_manager_->get_hook_status("BatchHookAdd"), HookStatus::Active); + EXPECT_EQ(*hook_manager_->get_hook_status("BatchHookMul"), HookStatus::Active); +} + +TEST_F(HookManagerTest, BatchToggle_SkipsUnknownIds) +{ + const auto ids = make_two_real_hooks(*hook_manager_); + // One real id plus one that does not exist: only the real one counts. + const std::vector mixed{ids[0], "NoSuchHook"}; + + EXPECT_EQ(hook_manager_->disable_hooks(mixed), 1u); + EXPECT_EQ(*hook_manager_->get_hook_status("BatchHookAdd"), HookStatus::Disabled); + // The other real hook was not in the batch, so it stays active. + EXPECT_EQ(*hook_manager_->get_hook_status("BatchHookMul"), HookStatus::Active); +} + +TEST_F(HookManagerTest, BatchToggle_IsIdempotent) +{ + const auto ids = make_two_real_hooks(*hook_manager_); + const std::vector id_views{ids[0], ids[1]}; + + EXPECT_EQ(hook_manager_->disable_hooks(id_views), 2u); + // Disabling again is a success per hook (disable is idempotent). + EXPECT_EQ(hook_manager_->disable_hooks(id_views), 2u); +} + +TEST_F(HookManagerTest, EnableAllAndDisableAll_RealHooks) +{ + make_two_real_hooks(*hook_manager_); + + EXPECT_EQ(hook_manager_->disable_all_hooks(), 2u); + EXPECT_EQ(*hook_manager_->get_hook_status("BatchHookAdd"), HookStatus::Disabled); + EXPECT_EQ(*hook_manager_->get_hook_status("BatchHookMul"), HookStatus::Disabled); + + EXPECT_EQ(hook_manager_->enable_all_hooks(), 2u); + EXPECT_EQ(*hook_manager_->get_hook_status("BatchHookAdd"), HookStatus::Active); + EXPECT_EQ(*hook_manager_->get_hook_status("BatchHookMul"), HookStatus::Active); +} + +TEST_F(HookManagerTest, BatchToggle_EmptyInputs) +{ + // No hooks and an empty span: both batch entry points report zero work. + const std::vector empty; + EXPECT_EQ(hook_manager_->enable_hooks(empty), 0u); + EXPECT_EQ(hook_manager_->enable_all_hooks(), 0u); + EXPECT_EQ(hook_manager_->disable_all_hooks(), 0u); +} + TEST_F(HookManagerTest, RealInlineHook_Remove) { void *original_trampoline = nullptr;