Skip to content

feat: mod authoring helpers#68

Merged
tkhquang merged 2 commits into
mainfrom
feat/v3.1.0-mod-authoring-helpers
Apr 23, 2026
Merged

feat: mod authoring helpers#68
tkhquang merged 2 commits into
mainfrom
feat/v3.1.0-mod-authoring-helpers

Conversation

@tkhquang

@tkhquang tkhquang commented Apr 23, 2026

Copy link
Copy Markdown
Owner

Summary

Adds six helpers that absorb patterns mod authors have been reimplementing:

  • Scanner::resolve_cascade + resolve_cascade_with_prologue_fallback for multi-candidate AOB resolution with RIP and hooked-prologue recovery
  • HookManager cross-module duplicate-hook detection (E9 / EB / FF 25)
  • Config::register_press_combo with InputBindingGuard RAII and live-update via InputManager::update_binding_combos
  • Filesystem::get_runtime_directory_utf8 cached UTF-8 sibling
  • DMK::Bootstrap DllMain lifecycle helper with loader-lock-safe detach and request_shutdown pre-unload API
  • DMK::StoppableWorker RAII std::jthread wrapper with loader-lock-aware teardown

Plus Logger::configure gains a default timestamp_fmt argument.

Backwards compatibility

No breaking changes. Two new enum values appended: HookError::TargetAlreadyHookedInProcess, ResolveError::PrologueFallbackNotApplicable.

Test plan

  • 932 tests pass on MinGW debug (was 874 baseline, +58 new)
  • New coverage: Bootstrap lifecycle, StoppableWorker destruction + exception firewall, scanner cascade hard paths (fallback hit, short-tail rejection), duplicate-hook strict/layered modes, concurrent input rebind
  • Docs updated: README.md feature rows, AGENTS.md tree, new section in docs/misc/aob-signatures.md covering the cascade API

Summary by CodeRabbit

Release Notes v3.1.0

  • New Features

    • Added DLL bootstrap utilities for managed lifecycle attach/detach
    • Added StoppableWorker—RAII thread abstraction for background tasks
    • Added cascade address resolution with prologue-fallback support
    • Added dynamic input combo binding updates at runtime
    • Added UTF-8 support for runtime directory queries
    • Added strict duplicate inline hook detection mode
  • Improvements

    • Logger::configure() timestamp format parameter now optional
  • Documentation

    • Expanded pattern resolution guide with cascade resolution techniques

- 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.
@tkhquang tkhquang self-assigned this Apr 23, 2026
@tkhquang tkhquang changed the title feat: v3.1.0 mod authoring helpers feat: mod authoring helpers Apr 23, 2026
@coderabbitai

coderabbitai Bot commented Apr 23, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@tkhquang has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 31 minutes and 3 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 31 minutes and 3 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: bc680a13-d3e1-4e9a-a5f0-1a15b5155868

📥 Commits

Reviewing files that changed from the base of the PR and between 9e7d867 and 44965aa.

📒 Files selected for processing (9)
  • README.md
  • include/DetourModKit/bootstrap.hpp
  • include/DetourModKit/config.hpp
  • src/bootstrap.cpp
  • src/input.cpp
  • src/scanner.cpp
  • src/x86_decode.hpp
  • tests/test_bootstrap.cpp
  • tests/test_config.cpp
📝 Walkthrough

Walkthrough

This PR introduces DLL lifecycle management (bootstrap.hpp), a stoppable worker abstraction wrapping std::jthread (worker.hpp), cascade address resolution for pattern scanning with prologue fallback, runtime binding combo updates for input configuration, duplicate inline-hook detection, UTF-8 filesystem accessors, and extends logger/scanner/input/config/hook-manager APIs with error handling and new capabilities, supported by comprehensive test coverage.

Changes

Cohort / File(s) Summary
Manifest & Version
CMakeLists.txt, AGENTS.md, README.md
Project version bumped to 3.1.0; public header inventory expanded (bootstrap, worker added; filesystem re-documented); module catalog updated with new modules and API enhancements.
Bootstrap Lifecycle Management
include/DetourModKit/bootstrap.hpp, src/bootstrap.cpp
New DLL attach/detach handler with async logger config, process-name gating, single-instance mutex enforcement, background worker lifecycle, and shutdown coordination via events.
Stoppable Worker RAII
include/DetourModKit/worker.hpp, src/worker.cpp
New non-copyable, non-movable RAII wrapper around std::jthread with cooperative stop signaling, loader-lock-aware detach-on-shutdown, and status reporting.
Cascade Address Resolution
include/DetourModKit/scanner.hpp, src/scanner.cpp, docs/misc/aob-signatures.md, src/x86_decode.hpp
New cascade candidate resolver with typed error codes, prologue-fallback pattern rewriter, x86 instruction decoders (E9/EB/FF25), and documentation of multi-candidate resolution strategy.
Input Binding Guard & Combo Updates
include/DetourModKit/config.hpp, src/config.cpp, include/DetourModKit/input.hpp, src/input.cpp
Move-only InputBindingGuard RAII type, register_press_combo config API, and runtime combo swapping (update_binding_combos/update_combos) with thread-safe mutex locking and cache invalidation.
Hook Manager Enhancements
include/DetourModKit/hook_manager.hpp, src/hook_manager.cpp
New error code TargetAlreadyHookedInProcess and HookConfig::fail_if_already_hooked flag enabling cross-module duplicate detection via x86 inline-jump pattern inspection.
Filesystem UTF-8 Support
include/DetourModKit/filesystem.hpp, src/filesystem.cpp
New cached get_runtime_directory_utf8() accessor with Windows UTF-8 multibyte conversion and fallback behavior ("." on conversion failure).
Logger Optional Timestamp
include/DetourModKit/logger.hpp, src/logger.cpp
Logger::configure timestamp format parameter now optional with DEFAULT_TIMESTAMP_FORMAT default.
Umbrella Header Integration
include/DetourModKit.hpp
New includes for bootstrap/worker modules; added namespace alias DMKBootstrap and convenience type aliases DMKStoppableWorker, DMKInputBindingGuard (guarded by DMK_NO_SHORT_NAMES).
Comprehensive Test Suite
tests/test_bootstrap.cpp, tests/test_worker.cpp, tests/test_config.cpp, tests/test_filesystem.cpp, tests/test_hook_manager.cpp, tests/test_input.cpp, tests/test_logger.cpp, tests/test_scanner.cpp
New/extended GTest coverage for DLL lifecycle, worker threading, input binding guards, combo updates, cascade resolution, duplicate hook detection, UTF-8 filesystem, and optional logger config.

Sequence Diagram(s)

sequenceDiagram
    participant DLL as DLL Module
    participant Boot as Bootstrap
    participant Log as Logger
    participant Worker as StoppableWorker
    participant Init as User Init/Shutdown
    participant DMK as DMK_Shutdown

    DLL->>Boot: on_dll_attach(hMod, ModInfo, init_fn, shutdown_fn)
    Boot->>Boot: Validate process name, enforce instance mutex
    Boot->>Log: Configure async logger
    Log-->>Boot: Ready
    Boot->>Worker: Start lifecycle worker
    Worker->>Init: Run init_fn() in background
    Init-->>Worker: init complete
    Worker->>Worker: Wait on shutdown event
    
    Note over DLL,Init: DLL remains loaded, worker spinning...
    
    DLL->>Boot: request_shutdown() or on_dll_detach
    Boot->>Worker: Signal shutdown event
    Worker->>Init: Run shutdown_fn()
    Init-->>Worker: cleanup complete
    Worker->>DMK: Call DMK_Shutdown()
    DMK-->>Boot: Shutdown complete
    Boot->>DLL: Detach (close handles, release mutex)
Loading
sequenceDiagram
    participant Scanner as Scanner::resolve_cascade
    participant Parser as AOB Parser
    participant Memory as Process Memory
    participant Decoder as x86_decode (E9/EB/FF25)
    participant Result as ResolveHit / ResolveError

    Scanner->>Scanner: Validate candidates not empty
    loop For each AddrCandidate
        Scanner->>Parser: Parse AOB pattern
        alt Pattern parse fails
            Scanner->>Result: Return AllPatternsInvalid
        else Pattern valid
            Scanner->>Memory: Scan executable regions for match
            alt No match found
                Scanner->>Result: Continue to next candidate
            else Match found at Address
                Scanner->>Decoder: Decode via ResolveMode (Direct/RipRelative)
                Decoder-->>Scanner: Resolved address
                Scanner->>Result: Return ResolveHit { address, winning_name }
            end
        end
    end
    
    alt All candidates exhausted without match
        Scanner->>Result: Return NoMatch error
    end
Loading
sequenceDiagram
    participant Config as Config::register_press_combo
    participant InputMgr as InputManager
    participant InputPoller as InputPoller (polling thread)
    participant Guard as InputBindingGuard
    participant Handler as User on_press Callback

    Config->>InputMgr: Update current_combos in config
    Config->>InputMgr: Call update_binding_combos(name, combos)
    
    alt InputManager running (poller active)
        InputMgr->>InputPoller: Take bindings_rw_mutex (exclusive)
        InputPoller->>InputPoller: Swap keys/modifiers, recompute cache
        InputPoller-->>InputMgr: Unlock
    else InputManager not started yet
        InputMgr->>InputMgr: Update pending_bindings_
    end
    
    Config->>InputMgr: Register InputManager press callback
    InputMgr-->>Config: Handler installed with atomic enabled flag
    
    Config-->>Guard: Return InputBindingGuard(name, enabled_flag_ptr)
    
    Note over InputPoller,Handler: During polling cycle...
    InputPoller->>InputPoller: Take bindings_rw_mutex (shared)
    InputPoller->>InputPoller: Poll registered bindings
    InputPoller-->>InputPoller: Release mutex, collect pending callbacks
    InputPoller->>Handler: Call on_press() if enabled_flag is true
    Handler-->>InputPoller: Callback complete
    
    Note over Guard: Later - Guard destruction or release()
    Guard->>Guard: Set enabled_flag to false atomically
    Guard->>Handler: Callback disabled (no further invocations)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: mod authoring helpers' clearly and concisely describes the main objective of the changeset, which adds mod authoring-focused features including Bootstrap lifecycle management, StoppableWorker threading utilities, Scanner cascade resolution, and input binding controls.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
README.md (1)

134-134: ⚠️ Potential issue | 🟡 Minor

Doc inconsistency: Filesystem bullet still says "wide-string API" only.

Line 23 of the features table correctly advertises "wide-string and UTF-8 APIs", but the bullet inside the collapsible "Format, Filesystem, Math, and Version Utilities" section at line 134 still reads Module directory resolution (wide-string API). — this should be updated to match (e.g. "wide-string and UTF-8 APIs") so users discovering the new get_runtime_directory_utf8() from the detail section see it too.

Proposed fix
-- **Filesystem** (`filesystem.hpp`): Module directory resolution (wide-string API).
+- **Filesystem** (`filesystem.hpp`): Module directory resolution (wide-string and UTF-8 APIs).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` at line 134, Update the README bullet in the "Format, Filesystem,
Math, and Version Utilities" section so it matches the features table by
changing the Filesystem entry from "Module directory resolution (wide-string
API)" to indicate both APIs (e.g., "Module directory resolution (wide-string and
UTF-8 APIs)"); mention the header name `filesystem.hpp` and the new API symbol
`get_runtime_directory_utf8()` in the revised text so readers can discover the
UTF-8 API from the detailed section.
🧹 Nitpick comments (11)
tests/test_filesystem.cpp (1)

104-115: Consider consolidating into the existing FilesystemTest suite for consistency.

The new tests use a separate suite name FilesystemUtf8 while every other test in this file uses FilesystemTest (e.g. FilesystemTest.GetRuntimeDirectory_CachedResult). Aligning to FilesystemTest.GetRuntimeDirectoryUtf8_* would keep the GTest output grouping consistent and simplify filtering. Also, a NoTrailingSeparator/IsAbsolute assertion analogous to the wide-string tests would strengthen coverage of the UTF-8 variant.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_filesystem.cpp` around lines 104 - 115, Rename and merge the two
new tests into the existing FilesystemTest suite and expand assertions: change
TEST(FilesystemUtf8, ReturnsNonEmpty) to TEST_F(FilesystemTest,
GetRuntimeDirectoryUtf8_ReturnsNonEmpty) and TEST(FilesystemUtf8, Cached) to
TEST_F(FilesystemTest, GetRuntimeDirectoryUtf8_Cached) (or use TEST with suite
name FilesystemTest if other tests are non-fixture), call
Filesystem::get_runtime_directory_utf8() as before, assert non-empty and
equality for caching, and add the same additional checks used for the
wide-string tests (e.g., NoTrailingSeparator and IsAbsolute assertions
referencing the runtime directory UTF-8 result) so the UTF-8 tests mirror the
coverage and naming convention of
FilesystemTest.GetRuntimeDirectory_CachedResult.
tests/test_config.cpp (1)

1286-1313: InputBindingGuard tests look solid; one small suggestion.

The three new tests cover default-constructed inactivity, idempotent release(), and move-transfer. One edge worth adding cheaply: verify that destroying an active guard also clears the shared flag (RAII contract), e.g.:

TEST(InputBindingGuard, DestructorReleasesFlag)
{
    auto flag = std::make_shared<std::atomic<bool>>(true);
    { Config::InputBindingGuard g("b", flag); }
    EXPECT_FALSE(flag->load());
}

Given the PR summary describes InputBindingGuard as RAII, covering the destructor path prevents regressions where only explicit release() clears the flag.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_config.cpp` around lines 1286 - 1313, Add a test that verifies
InputBindingGuard's destructor clears the shared flag to enforce the RAII
contract: create a shared_ptr<std::atomic<bool>> initialized true, construct a
scoped Config::InputBindingGuard (use a distinguishing test name like
"DestructorReleasesFlag"), let it go out of scope, and then assert the
flag->load() is false; this complements the existing tests for release() and
move semantics and ensures implicit destruction also calls the same cleanup as
release().
src/filesystem.cpp (1)

144-169: to_utf8 logic is correct; consider tightening the overflow fallback.

Two minor observations:

  1. wide.size() > INT_MAX returning "." is defensive but effectively unreachable given Windows path limits (MAX_MODULE_PATH = 32768 upstream). Fine as-is.
  2. Returning "." when WideCharToMultiByte fails on a non-empty wide path silently masks a real failure (e.g. lone surrogate). The wide resolver already logs to stderr on failure; mirroring that here would aid diagnosability:
     if (needed <= 0)
     {
+        std::cerr << "[DMK Filesystem WARNING] WideCharToMultiByte(CP_UTF8) sizing failed. Error: "
+                  << GetLastError() << '\n';
         return ".";
     }

Not blocking — caching means the failure would only fire once per process.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/filesystem.cpp` around lines 144 - 169, In to_utf8, don't silently return
"." on WideCharToMultiByte failures — emit a diagnostic to stderr (like the wide
resolver does) including context (the failing function name to_utf8 and the
problematic wide string or its length) and the system error (GetLastError or
formatted message) before returning the fallback; leave the INT_MAX check as-is.
Locate the failure branches in to_utf8 (the two places that return "." when
needed <= 0 or written <= 0) and add the stderr logging there so callers have
actionable diagnostics.
tests/test_logger.cpp (1)

1517-1536: Minor: new test does not clean up under exception and leaks the counter helper across fixtures.

The TEST(LoggerConfigureOverload, ...) is a free test (not LoggerTest fixture), so if flush()/info() throws, the temp log file leaks. Wrapping the remove in a try/catch (as done elsewhere in this file) and optionally asserting content-before-remove would match the surrounding style. Not a blocker.

Proposed tweak
-    EXPECT_TRUE(std::filesystem::exists(path));
-    std::filesystem::remove(path);
+    EXPECT_TRUE(std::filesystem::exists(path));
+    try { std::filesystem::remove(path); }
+    catch (const std::filesystem::filesystem_error &) {}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_logger.cpp` around lines 1517 - 1536, The test should guarantee
cleanup of the temporary log file even if Logger::get_instance().info() or
flush() throws and avoid leaking the temp file/counter state across failures:
wrap the file removal in a try/catch (mirroring other tests), check
EXPECT_TRUE(std::filesystem::exists(path)) or assert file content before remove,
and in the catch block attempt std::filesystem::remove(path) (or ignore errors)
so the temp file created by make_logger_overload_path() is always removed;
modify the TEST(LoggerConfigureOverload...) to perform removal inside a safe
try/catch around the remove call while leaving make_logger_overload_path(),
Logger::configure, Logger::get_instance().info(), and flush() usage unchanged.
include/DetourModKit/config.hpp (1)

5-16: Redundant forward declaration of Logger.

"DetourModKit/logger.hpp" is now included at line 5, which makes the class Logger; forward declaration on line 16 redundant.

♻️ Proposed cleanup
 namespace DetourModKit
 {
-    class Logger;
-
     /**
      * `@namespace` Config
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@include/DetourModKit/config.hpp` around lines 5 - 16, The file contains a
redundant forward declaration "class Logger;" because DetourModKit/logger.hpp is
already included; remove the forward declaration to avoid duplication. Locate
the forward declaration token "class Logger;" in the config.hpp header and
delete that single line, leaving the existing `#include` "DetourModKit/logger.hpp"
and any usages of Logger intact (no other code changes needed).
src/config.cpp (1)

606-611: Avoid spurious update_binding_combos warning during initial registration.

register_key_combo (line 606) immediately fires its setter with the parsed default combos. That setter calls InputManager::update_binding_combos(binding_name_str, ...) before the binding is registered via register_press (line 611). Per the implementation in src/input.cpp:713-768 (update_binding_combos), that lookup falls through the "name not found" branch and emits a Debug-level log on every register_press_combo call. Functionally harmless, but noisy.

Consider gating the forwarding call on registration having actually happened, e.g. by flipping a shared flag after register_press returns and letting the setter check it, or by reordering so register_press runs first and register_key_combo is registered after (the initial INI load still picks up the correct value since load() invokes each item's setter). Example:

♻️ Proposed fix
     auto enabled_flag = std::make_shared<std::atomic<bool>>(true);
     auto current_combos = std::make_shared<KeyComboList>(parse_key_combo_list(std::string(default_value)));
     std::string binding_name_str(input_binding_name);
+    auto binding_live = std::make_shared<std::atomic<bool>>(false);

-    register_key_combo(section, ini_key, log_name, [current_combos, binding_name_str](const KeyComboList &combos)
+    register_key_combo(section, ini_key, log_name, [current_combos, binding_name_str, binding_live](const KeyComboList &combos)
                        {
                            *current_combos = combos;
-                           InputManager::get_instance().update_binding_combos(binding_name_str, combos); }, default_value);
+                           if (binding_live->load(std::memory_order_acquire))
+                           {
+                               InputManager::get_instance().update_binding_combos(binding_name_str, combos);
+                           } }, default_value);

     InputManager::get_instance().register_press(
         binding_name_str,
         *current_combos,
         [enabled_flag, cb = std::move(on_press)]()
         {
             if (cb && enabled_flag->load(std::memory_order_acquire))
             {
                 cb();
             }
         });
+    binding_live->store(true, std::memory_order_release);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/config.cpp` around lines 606 - 611, The setter passed to
register_key_combo is calling
InputManager::update_binding_combos(binding_name_str, ...) before the binding is
registered via InputManager::register_press, causing noisy "name not found"
debug logs; fix by ensuring the forwarding only runs after registration: either
register the press first (call InputManager::get_instance().register_press(...)
before register_key_combo) or add a small shared flag/atomic captured by the
setter (e.g., bool registered = false captured by reference) that you flip to
true immediately after register_press returns, and have the setter early-return
unless registered is true; reference the lambda passed into register_key_combo,
InputManager::update_binding_combos, register_press, and binding_name_str to
locate the exact spots to change.
src/x86_decode.hpp (1)

47-72: decode_ff25_indirect correctly assumes x86-64 RIP-relative semantics; no fix needed for current scope.

The instruction semantics claim is technically accurate: FF 25 disp32 is RIP-relative [RIP+disp32] on x86-64 (long mode) and absolute [disp32] on x86-32. However, DetourModKit is explicitly built for x86-64 only:

  • CMakePresets.json defines only native-arch presets (mingw-debug, mingw-release, msvc-debug, msvc-release) with no -m32 or 32-bit explicit flags
  • CMakeLists.txt has no 32-bit build logic or architecture detection
  • README emphasizes x86-64 features (RIP resolution) and Windows 10+ targeting
  • The #ifdef _WIN64 code in platform.hpp is Windows API compatibility (PEB access methods), not for 32-bit library builds

The decode_ff25_indirect function correctly implements x86-64 semantics for the current, 64-bit-only project. If future 32-bit support is planned, the proposed #ifdef _WIN64 conditional would be appropriate, but it is not needed now.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/x86_decode.hpp` around lines 47 - 72, The decode_ff25_indirect function
correctly assumes x86-64 RIP-relative semantics; do not change the logic, but
make the intent explicit: keep decode_ff25_indirect as-is and add a short
comment above it stating it implements x86-64 RIP-relative FF 25 disp32
semantics (or add a compile-time guard such as static_assert(sizeof(void*)==8) /
`#ifdef` _WIN64) to document/guard the 64-bit-only assumption for future readers.
src/input.cpp (2)

180-183: Redundant has_gamepad_bindings_ store in the constructor.

Line 180 stores the gamepad-binding flag, and then recompute_modifier_caches_locked() on line 182 stores it again at line 201. The first store is dead. Either drop it, or make recompute_modifier_caches_locked not touch has_gamepad_bindings_ (leaving the constructor as the sole initializer and update_combos re-deriving only when bindings change). Current behavior is correct but mildly confusing.

♻️ Suggested cleanup
-        has_gamepad_bindings_.store(scan_for_gamepad_bindings(bindings_), std::memory_order_relaxed);
         name_index_.reserve(bindings_.size());
         recompute_modifier_caches_locked();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/input.cpp` around lines 180 - 183, The initial store to
has_gamepad_bindings_ using scan_for_gamepad_bindings(bindings_) in the
constructor is redundant because recompute_modifier_caches_locked() reassigns
has_gamepad_bindings_ again; remove the first store in the constructor (leave
recompute_modifier_caches_locked() as the single place that sets
has_gamepad_bindings_) so initialization is not done twice and the code is
clearer.

344-351: Allocate pending outside the hot loop to amortize cost.

PendingCallback is declared and a fresh std::vector<PendingCallback> pending is default-constructed every poll cycle (~every 5 ms). Moving the vector out of the while (!stop_token.stop_requested()) loop and calling pending.clear() at the top of each iteration (plus a one-time reserve(bindings_.size())) keeps the capacity around and avoids repeated heap churn on the poll thread. Minor, but it is the per-tick path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/input.cpp` around lines 344 - 351, The PendingCallback type is fine but
the std::vector<PendingCallback> pending is being constructed each poll cycle
inside the while (!stop_token.stop_requested()) loop; move the declaration of
pending outside that loop so it is only constructed once, call pending.clear()
at the top of each iteration, and after construction do a one-time
pending.reserve(bindings_.size()) to keep capacity stable and avoid per-tick
allocations; adjust any uses of pending in the loop accordingly.
src/bootstrap.cpp (1)

223-229: request_shutdown reads g_shutdown_event without synchronization.

g_shutdown_event is written on the DllMain thread inside on_dll_attach (line 206) and cleared in on_dll_detach (line 316), but request_shutdown() is documented as "Safe to call from any thread" (see include/DetourModKit/bootstrap.hpp:110). The read of the plain HANDLE global is technically racy with respect to those writes. In practice Win32 thread creation inserts a release barrier, so callers invoking request_shutdown() from the worker-facing side after attach completes will observe the event; and the documented contract is to call this before FreeLibrary, which also precludes racing on_dll_detach. Still, modeling these handles as std::atomic<HANDLE> (or guarding with a small mutex) would make the lifetime invariants explicit and silence TSan-style tools.

Does any caller (test or mod scaffolding) invoke request_shutdown from a non-worker thread while on_dll_detach is concurrently running? If yes, the race is real and should be hardened; if not, the contract in the header is sufficient.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/bootstrap.cpp` around lines 223 - 229, The global g_shutdown_event is
accessed without synchronization in request_shutdown(), which races with writes
in on_dll_attach/on_dll_detach; change the global to std::atomic<HANDLE> (or
protect with a small mutex) and update all accesses: in on_dll_attach use
atomic.store(handle, std::memory_order_release), in on_dll_detach use
atomic.exchange(nullptr, std::memory_order_acq_rel) (close the old handle only
if non-null), and in request_shutdown load the handle with
std::memory_order_acquire and call SetEvent only if the loaded HANDLE is
non-null; update include/DetourModKit/bootstrap.hpp contract if needed. This
makes the lifetime explicit and removes the TSan race while preserving the
documented "safe to call from any thread" behavior.
src/scanner.cpp (1)

811-825: count_pattern_hits_bounded re-walks the full address space per iteration.

Each scan_executable_regions(pattern, n) call restarts VirtualQuery from address 0 and re-scans every committed executable region up to the Nth hit. Calling it for n = 1..max_hits+1 (5 times with kPrologueFallbackMaxHits = 4) redoes all prior work on each iteration — effectively O(max_hits² × regions). Given this only runs on cascade failure with tiny max_hits, it is not a practical issue, but exposing a dedicated count_pattern_hits(pattern, cap) in Scanner that walks regions once and stops at cap + 1 would make intent clearer and avoid the quadratic behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/scanner.cpp` around lines 811 - 825, The current
count_pattern_hits_bounded repeatedly calls
DetourModKit::Scanner::scan_executable_regions(pattern, n) which restarts region
scanning for every n; replace this quadratic behavior by adding or changing to a
single-pass implementation that walks executable regions once, uses the
CompiledPattern to search within each region (advancing an offset after each
match), increments hits and stops when hits > max_hits (i.e., cap+1), then
returns hits; update or add a Scanner method (e.g.,
Scanner::count_pattern_hits_bounded or Scanner::count_pattern_hits(pattern,
cap)) that performs this single-pass region iteration instead of repeated calls
to scan_executable_regions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@include/DetourModKit/bootstrap.hpp`:
- Around line 71-74: Add the [[nodiscard]] attribute to the on_dll_attach
declaration and its definition/implementation so callers cannot silently ignore
its BOOL result; update the prototype BOOL on_dll_attach(...) and the
corresponding function definition (the implementation symbol on_dll_attach) to
both be marked [[nodiscard]] to keep signature parity and avoid compiler
warnings/errors.
- Line 15: This public header currently includes <windows.h> and exposes Win32
types (e.g., HMODULE, BOOL/DllMain-facing signatures) which pollutes consumers;
either remove the include and replace Win32 types with neutral aliases (use
void* for OS handles and int for BOOL-returning functions) in bootstrap.hpp and
confine all actual Windows API usage and `#include` <windows.h> to the .cpp
implementation, or if the DllMain-facing API intentionally requires Windows
types, add an explicit `#ifdef` _WIN32 guard and a short comment explaining the
deliberate carve-out; update any symbols that reference HMODULE/BOOL in
bootstrap.hpp to the new typedefs or guarded signatures so the umbrella
DetourModKit.hpp no longer transits <windows.h>.

In `@src/bootstrap.cpp`:
- Line 31: Initialize the uninitialized stack arrays by changing the
declarations for exe_path and mutex_name to use brace-initialization;
specifically, update the declaration of char exe_path[MAX_PATH] and wchar_t
mutex_name[128] to use {} so they are zero-initialized before being passed to
GetModuleFileNameA and wsprintfW (references: exe_path, mutex_name,
GetModuleFileNameA, wsprintfW).

In `@src/hook_manager.cpp`:
- Around line 58-97: detect_existing_inline_hook currently flags any
cross-module JMP as HookedByOtherModule; change the heuristic to ignore known
legitimate thunks such as import/IAT indirect jumps and compiler/linker
forwarder stubs. After calling decode_prehook_destination in
detect_existing_inline_hook, examine the original instruction bytes at
target_address (e.g., detect opcode patterns FF 25, E9/Eb patterns) and for FF
25 check the RIP/displacement/memory operand address — if that memory operand
points into the target module's import/IAT/data section or to a known forwarder
thunk within the destination module (use
VirtualQuery/GetModuleHandleEx/GetModuleInformation to map addresses to
modules/sections), treat the target as not already hooked (set result.state =
PrehookState::Unhooked) instead of HookedByOtherModule; otherwise keep the
existing cross-module hook classification. Ensure you reference
decode_prehook_destination, detect_existing_inline_hook, PrehookDetection and
PrehookState when applying the change.

In `@src/scanner.cpp`:
- Around line 680-693: The RipRelative branch in resolve_candidate_match
currently does a reinterpret_cast to const int32_t* and dereferences it, which
risks alignment/aliasing UB and lacks a readability check; change this to
compute the target pointer (match_addr + c.disp_offset), call
Memory::is_readable(ptr, sizeof(int32_t)) and if that check fails return a safe
sentinel (e.g., 0) to reject the candidate gracefully, otherwise copy the 4
bytes into a local std::int32_t via std::memcpy and use that value when
computing the final address (maintain use of c.instr_end_offset and keep the
Direct branch unchanged); mirror the approach used by
Scanner::resolve_rip_relative for consistency.

In `@tests/test_bootstrap.cpp`:
- Around line 153-161: TearDown currently calls Bootstrap::request_shutdown()
and sig.wait_for_shutdown(...) but never invokes
Bootstrap::on_dll_detach(FALSE), leaving globals like g_shutdown_event,
g_worker_thread, g_instance_mutex, and g_detach_called set and causing future
Bootstrap::on_dll_attach() calls to short-circuit; after the existing
sig.wait_for_shutdown(kTestTimeout) completes in TearDown, call
Bootstrap::on_dll_detach(FALSE) to perform the full cleanup so the fixture is
self-contained and safe for subsequent tests.

In `@tests/test_scanner.cpp`:
- Around line 1936-1979: The test currently allows
resolve_cascade_with_prologue_fallback() to return NoMatch; instead make the
buffer contain a valid E9 rel32 that points from the call site (kOffset+5) to
the intended synthetic destination so the prologue fallback is actually
exercised, then assert fallback.has_value() unconditionally. Concretely: compute
rel32 = (uintptr_t)(buf.base + kOffset + 5 + rel) targetting the unique tail
(use synthetic_dest logic but write the computed rel into buf.base + kOffset +
1), remove the if/else that accepts NoMatch, and replace it with
ASSERT_TRUE(fallback.has_value()) followed by EXPECT_EQ(fallback->address,
reinterpret_cast<std::uintptr_t>(buf.base) + kOffset); this uses
resolve_cascade_with_prologue_fallback, Scanner::AddrCandidate ("cands"), and
the local fallback variable to locate the change.

---

Outside diff comments:
In `@README.md`:
- Line 134: Update the README bullet in the "Format, Filesystem, Math, and
Version Utilities" section so it matches the features table by changing the
Filesystem entry from "Module directory resolution (wide-string API)" to
indicate both APIs (e.g., "Module directory resolution (wide-string and UTF-8
APIs)"); mention the header name `filesystem.hpp` and the new API symbol
`get_runtime_directory_utf8()` in the revised text so readers can discover the
UTF-8 API from the detailed section.

---

Nitpick comments:
In `@include/DetourModKit/config.hpp`:
- Around line 5-16: The file contains a redundant forward declaration "class
Logger;" because DetourModKit/logger.hpp is already included; remove the forward
declaration to avoid duplication. Locate the forward declaration token "class
Logger;" in the config.hpp header and delete that single line, leaving the
existing `#include` "DetourModKit/logger.hpp" and any usages of Logger intact (no
other code changes needed).

In `@src/bootstrap.cpp`:
- Around line 223-229: The global g_shutdown_event is accessed without
synchronization in request_shutdown(), which races with writes in
on_dll_attach/on_dll_detach; change the global to std::atomic<HANDLE> (or
protect with a small mutex) and update all accesses: in on_dll_attach use
atomic.store(handle, std::memory_order_release), in on_dll_detach use
atomic.exchange(nullptr, std::memory_order_acq_rel) (close the old handle only
if non-null), and in request_shutdown load the handle with
std::memory_order_acquire and call SetEvent only if the loaded HANDLE is
non-null; update include/DetourModKit/bootstrap.hpp contract if needed. This
makes the lifetime explicit and removes the TSan race while preserving the
documented "safe to call from any thread" behavior.

In `@src/config.cpp`:
- Around line 606-611: The setter passed to register_key_combo is calling
InputManager::update_binding_combos(binding_name_str, ...) before the binding is
registered via InputManager::register_press, causing noisy "name not found"
debug logs; fix by ensuring the forwarding only runs after registration: either
register the press first (call InputManager::get_instance().register_press(...)
before register_key_combo) or add a small shared flag/atomic captured by the
setter (e.g., bool registered = false captured by reference) that you flip to
true immediately after register_press returns, and have the setter early-return
unless registered is true; reference the lambda passed into register_key_combo,
InputManager::update_binding_combos, register_press, and binding_name_str to
locate the exact spots to change.

In `@src/filesystem.cpp`:
- Around line 144-169: In to_utf8, don't silently return "." on
WideCharToMultiByte failures — emit a diagnostic to stderr (like the wide
resolver does) including context (the failing function name to_utf8 and the
problematic wide string or its length) and the system error (GetLastError or
formatted message) before returning the fallback; leave the INT_MAX check as-is.
Locate the failure branches in to_utf8 (the two places that return "." when
needed <= 0 or written <= 0) and add the stderr logging there so callers have
actionable diagnostics.

In `@src/input.cpp`:
- Around line 180-183: The initial store to has_gamepad_bindings_ using
scan_for_gamepad_bindings(bindings_) in the constructor is redundant because
recompute_modifier_caches_locked() reassigns has_gamepad_bindings_ again; remove
the first store in the constructor (leave recompute_modifier_caches_locked() as
the single place that sets has_gamepad_bindings_) so initialization is not done
twice and the code is clearer.
- Around line 344-351: The PendingCallback type is fine but the
std::vector<PendingCallback> pending is being constructed each poll cycle inside
the while (!stop_token.stop_requested()) loop; move the declaration of pending
outside that loop so it is only constructed once, call pending.clear() at the
top of each iteration, and after construction do a one-time
pending.reserve(bindings_.size()) to keep capacity stable and avoid per-tick
allocations; adjust any uses of pending in the loop accordingly.

In `@src/scanner.cpp`:
- Around line 811-825: The current count_pattern_hits_bounded repeatedly calls
DetourModKit::Scanner::scan_executable_regions(pattern, n) which restarts region
scanning for every n; replace this quadratic behavior by adding or changing to a
single-pass implementation that walks executable regions once, uses the
CompiledPattern to search within each region (advancing an offset after each
match), increments hits and stops when hits > max_hits (i.e., cap+1), then
returns hits; update or add a Scanner method (e.g.,
Scanner::count_pattern_hits_bounded or Scanner::count_pattern_hits(pattern,
cap)) that performs this single-pass region iteration instead of repeated calls
to scan_executable_regions.

In `@src/x86_decode.hpp`:
- Around line 47-72: The decode_ff25_indirect function correctly assumes x86-64
RIP-relative semantics; do not change the logic, but make the intent explicit:
keep decode_ff25_indirect as-is and add a short comment above it stating it
implements x86-64 RIP-relative FF 25 disp32 semantics (or add a compile-time
guard such as static_assert(sizeof(void*)==8) / `#ifdef` _WIN64) to document/guard
the 64-bit-only assumption for future readers.

In `@tests/test_config.cpp`:
- Around line 1286-1313: Add a test that verifies InputBindingGuard's destructor
clears the shared flag to enforce the RAII contract: create a
shared_ptr<std::atomic<bool>> initialized true, construct a scoped
Config::InputBindingGuard (use a distinguishing test name like
"DestructorReleasesFlag"), let it go out of scope, and then assert the
flag->load() is false; this complements the existing tests for release() and
move semantics and ensures implicit destruction also calls the same cleanup as
release().

In `@tests/test_filesystem.cpp`:
- Around line 104-115: Rename and merge the two new tests into the existing
FilesystemTest suite and expand assertions: change TEST(FilesystemUtf8,
ReturnsNonEmpty) to TEST_F(FilesystemTest,
GetRuntimeDirectoryUtf8_ReturnsNonEmpty) and TEST(FilesystemUtf8, Cached) to
TEST_F(FilesystemTest, GetRuntimeDirectoryUtf8_Cached) (or use TEST with suite
name FilesystemTest if other tests are non-fixture), call
Filesystem::get_runtime_directory_utf8() as before, assert non-empty and
equality for caching, and add the same additional checks used for the
wide-string tests (e.g., NoTrailingSeparator and IsAbsolute assertions
referencing the runtime directory UTF-8 result) so the UTF-8 tests mirror the
coverage and naming convention of
FilesystemTest.GetRuntimeDirectory_CachedResult.

In `@tests/test_logger.cpp`:
- Around line 1517-1536: The test should guarantee cleanup of the temporary log
file even if Logger::get_instance().info() or flush() throws and avoid leaking
the temp file/counter state across failures: wrap the file removal in a
try/catch (mirroring other tests), check
EXPECT_TRUE(std::filesystem::exists(path)) or assert file content before remove,
and in the catch block attempt std::filesystem::remove(path) (or ignore errors)
so the temp file created by make_logger_overload_path() is always removed;
modify the TEST(LoggerConfigureOverload...) to perform removal inside a safe
try/catch around the remove call while leaving make_logger_overload_path(),
Logger::configure, Logger::get_instance().info(), and flush() usage unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9d82f1ce-3a62-4681-b1cf-9c3c8fd9aa48

📥 Commits

Reviewing files that changed from the base of the PR and between cfcb2fa and 9e7d867.

📒 Files selected for processing (30)
  • AGENTS.md
  • CMakeLists.txt
  • README.md
  • docs/misc/aob-signatures.md
  • include/DetourModKit.hpp
  • include/DetourModKit/bootstrap.hpp
  • include/DetourModKit/config.hpp
  • include/DetourModKit/filesystem.hpp
  • include/DetourModKit/hook_manager.hpp
  • include/DetourModKit/input.hpp
  • include/DetourModKit/logger.hpp
  • include/DetourModKit/scanner.hpp
  • include/DetourModKit/worker.hpp
  • src/bootstrap.cpp
  • src/config.cpp
  • src/filesystem.cpp
  • src/hook_manager.cpp
  • src/input.cpp
  • src/logger.cpp
  • src/scanner.cpp
  • src/worker.cpp
  • src/x86_decode.hpp
  • tests/test_bootstrap.cpp
  • tests/test_config.cpp
  • tests/test_filesystem.cpp
  • tests/test_hook_manager.cpp
  • tests/test_input.cpp
  • tests/test_logger.cpp
  • tests/test_scanner.cpp
  • tests/test_worker.cpp
💤 Files with no reviewable changes (1)
  • src/logger.cpp

Comment thread include/DetourModKit/bootstrap.hpp
Comment thread include/DetourModKit/bootstrap.hpp Outdated
Comment thread src/bootstrap.cpp Outdated
Comment thread src/hook_manager.cpp
Comment thread src/scanner.cpp
Comment thread tests/test_bootstrap.cpp
Comment thread tests/test_scanner.cpp
- [[nodiscard]] on Bootstrap::on_dll_attach
- Zero-init exe_path and mutex_name stack arrays
- Harden RipRelative disp read with is_readable + memcpy
- TearDown in test_bootstrap calls on_dll_detach for full cleanup
- Make prologue-fallback scanner test deterministic (real E9 rel32)
- README: Filesystem bullet lists both wide and UTF-8 APIs
- Remove redundant class Logger forward decl in config.hpp
- Drop redundant has_gamepad_bindings_ store in InputPoller ctor
- Hoist PendingCallback + pending vector out of poll loop
- static_assert(sizeof(void*)==8) on decode_ff25_indirect
- New test: InputBindingGuard.DestructorReleasesFlag
@tkhquang tkhquang merged commit f0cc945 into main Apr 23, 2026
2 checks passed
@tkhquang tkhquang deleted the feat/v3.1.0-mod-authoring-helpers branch April 23, 2026 19:05
tkhquang added a commit that referenced this pull request Apr 23, 2026
- logger: emplace AsyncLogger into the leak vector unconditionally
  under loader lock; use_count() is not a reliable proxy for
  'no other owners' when concurrent log() callers hold temporaries
  obtained before exchange()
- profiler: add static_assert + guardrail comment forbidding
  load-then-store RMW on the seqlock sequence counter
- profiler: correct docs to describe what const char (&)[N] actually
  guarantees (rejects pointer decay, accepts any array)
- test_hook_manager: add pre-release EXPECT_FALSE(shutdown_returned)
  to catch premature-return regressions directly
- test_scanner: replace leak-prone raw VirtualAlloc/Protect/Free with
  existing ExecBuffer RAII helper
- test_profiler: rename the concurrent-wrap test to match what it
  verifies and add per-event dur range check for torn-sample detection
- README + hot-reload guide: document Mod Bootstrap and StoppableWorker
  helpers added in #68
tkhquang added a commit that referenced this pull request Apr 23, 2026
* fix: harden profiler, hook manager, and logger for v3.1.0

Address v3.1.0 audit findings: switch profiler seqlock to
fetch_add(acq_rel), serialize HookManager destructor via
mutator_gate with loader-lock module pinning, make logger
shutdown leak vector append-only, enforce scanner prologue
fallback hit cap, raise input cardinality mismatch to warning,
scope scanner -O2 downgrade to Release only, and make
ScopedProfile compile-time literal-only.

* fix: address v3.1.0 audit follow-ups

- logger: emplace AsyncLogger into the leak vector unconditionally
  under loader lock; use_count() is not a reliable proxy for
  'no other owners' when concurrent log() callers hold temporaries
  obtained before exchange()
- profiler: add static_assert + guardrail comment forbidding
  load-then-store RMW on the seqlock sequence counter
- profiler: correct docs to describe what const char (&)[N] actually
  guarantees (rejects pointer decay, accepts any array)
- test_hook_manager: add pre-release EXPECT_FALSE(shutdown_returned)
  to catch premature-return regressions directly
- test_scanner: replace leak-prone raw VirtualAlloc/Protect/Free with
  existing ExecBuffer RAII helper
- test_profiler: rename the concurrent-wrap test to match what it
  verifies and add per-event dur range check for torn-sample detection
- README + hot-reload guide: document Mod Bootstrap and StoppableWorker
  helpers added in #68
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant