Skip to content

Commit 4661a3c

Browse files
authored
chore(release): bump to 3.7.0 with audit hardening (#128)
1 parent 5053e9b commit 4661a3c

16 files changed

Lines changed: 154 additions & 36 deletions

File tree

.github/workflows/release.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,41 @@ on:
2727
default: false
2828

2929
jobs:
30+
validate-version:
31+
name: Validate release version matches CMake
32+
runs-on: ubuntu-latest
33+
34+
steps:
35+
- name: Checkout code
36+
uses: actions/checkout@v4
37+
38+
- name: Compare release input against project(VERSION) in CMakeLists.txt
39+
run: |
40+
# Single-source the release version from CMakeLists.txt project(VERSION ...): the shipped
41+
# DetourModKitConfigVersion.cmake and the DMK_VERSION_* macros are generated from that number, so a tag
42+
# whose version disagrees with it would publish a package whose find_package check contradicts the tag.
43+
# Fail closed here rather than shipping the mismatch.
44+
INPUT_VERSION="${{ github.event.inputs.version }}"
45+
# Strip any pre-release / build suffix (e.g. 3.7.0-beta -> 3.7.0); CMake project() is numeric MAJOR.MINOR.PATCH.
46+
INPUT_CORE="${INPUT_VERSION%%-*}"
47+
PROJECT_VERSION="$(grep -oP 'project\(DetourModKit\s+VERSION\s+\K[0-9]+\.[0-9]+\.[0-9]+' CMakeLists.txt)"
48+
echo "Release input version : ${INPUT_VERSION} (core: ${INPUT_CORE})"
49+
echo "CMakeLists project ver: ${PROJECT_VERSION}"
50+
if [ -z "${PROJECT_VERSION}" ]; then
51+
echo "::error::Could not parse project(VERSION ...) from CMakeLists.txt"
52+
exit 1
53+
fi
54+
if [ "${INPUT_CORE}" != "${PROJECT_VERSION}" ]; then
55+
echo "::error::Release version '${INPUT_VERSION}' does not match CMakeLists.txt project(VERSION) '${PROJECT_VERSION}'. Bump project(VERSION) first so the shipped ConfigVersion and DMK_VERSION_* macros match the tag."
56+
exit 1
57+
fi
58+
echo "Version check passed."
59+
shell: bash
60+
3061
build-mingw:
3162
name: Build for MinGW (g++)
3263
runs-on: windows-latest
64+
needs: validate-version
3365
outputs:
3466
artifact_name: ${{ steps.determine-artifact-name-mingw.outputs.ARTIFACT_NAME }}
3567
artifact_zip_filename: ${{ steps.determine-artifact-name-mingw.outputs.ARTIFACT_NAME }}
@@ -138,6 +170,7 @@ jobs:
138170
build-msvc:
139171
name: Build for MSVC (Visual Studio)
140172
runs-on: windows-latest
173+
needs: validate-version
141174
outputs:
142175
artifact_name: ${{ steps.determine-artifact-name-msvc.outputs.ARTIFACT_NAME }}
143176
artifact_zip_filename: ${{ steps.determine-artifact-name-msvc.outputs.ARTIFACT_NAME }}

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,7 @@ These are called at 60+ fps from game hook callbacks. Never add allocations, exc
413413
- **Do not weaken** atomic memory orderings without proving correctness.
414414
- **Do not skip** running the test suite before committing.
415415
- **Do not publish** release packages before debug tests, release builds, and installed-package smoke tests pass for both MinGW and MSVC.
416+
- **Do not tag** a release whose version differs from `CMakeLists.txt` `project(VERSION ...)`. The release version is single-sourced from there: the generated `DetourModKitConfigVersion.cmake` and the `DMK_VERSION_*` macros derive from it, so a tag that disagrees would ship a package whose `find_package` version check and `DMK_VERSION_AT_LEAST` contradict the tag. The `validate-version` job in `.github/workflows/release.yml` fails closed when the dispatch `version` input does not match, so bump `project(VERSION)` first.
416417
- **Do not add** Windows API calls without `#ifdef _WIN32` guards in headers (implementation files are Windows-only, but headers should remain clean).
417418
- **Do not commit** build artifacts, `.exe`, `.a`, `.lib`, `.obj`, or `.pdb` files.
418419
- **Do not remove** or weaken existing tests. Add new tests for new code.

CMakeLists.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
cmake_minimum_required(VERSION 3.25)
22

3-
project(DetourModKit VERSION 3.6.1 LANGUAGES CXX)
3+
project(DetourModKit VERSION 3.7.0 LANGUAGES CXX)
44

55
# GNUInstallDirs defines CMAKE_INSTALL_LIBDIR / BINDIR / INCLUDEDIR / DOCDIR. It must be included before any
66
# install() rule reads those variables; otherwise they expand to empty and components land at the install-prefix

docs/hot-reload/README.md

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -343,8 +343,9 @@ static void unload_logic_dll()
343343
}
344344
345345
// Brief sleep to allow any in-flight hook callbacks to complete.
346-
// SafetyHook freezes threads during hook removal, but callbacks
347-
// that were already past the hook entry point need time to return.
346+
// SafetyHook does not freeze threads during removal: it relocates only a
347+
// thread that faults on the patched page during the brief rewrite window,
348+
// so a callback already past the hook entry must return on its own.
348349
Sleep(CALLBACK_DRAIN_MS);
349350
350351
FreeLibrary(s_logic_module);
@@ -653,7 +654,7 @@ With this setup, the workflow is always **build, then press reload key**. The po
653654

654655
**Problem:** A hook callback may be executing on the game's thread when you trigger a reload. If the logic DLL is unloaded while a callback is mid-execution, the game crashes (code page unmapped leads to access violation).
655656

656-
**How DMK handles this:** SafetyHook's `remove_all_hooks()` freezes all threads, patches the original bytes back, then resumes threads. Any thread that was inside a hook trampoline will now execute the original function code. This is safe as long as:
657+
**How DMK handles this:** SafetyHook's `remove_all_hooks()` patches the original bytes back without freezing threads. While it rewrites the prologue it strips execute on the patched page, and a vectored exception handler relocates the instruction pointer of any thread that *faults* on that page during the rewrite window; a thread already running inside the trampoline or detour body is **not** relocated. So removal is safe only if the hooked function is quiescent at that moment, which is why you must drain or quiesce callers before unloading. As long as that holds:
657658

658659
- The hook callback does not store persistent pointers into the logic DLL's code/data segments.
659660
- The hook callback does not spawn threads that outlive the DLL.
@@ -1216,7 +1217,7 @@ If DMK could only ship one of the two, `DMK_Shutdown` would cover ~90% of consum
12161217

12171218
### Pre-unload contract: worker-thread quiescence
12181219

1219-
`Bootstrap::on_logic_dll_unload(_all)` removes hooks and bindings, but it cannot prove that every consumer-owned worker thread has stopped firing those hooks. A worker thread that calls into a detoured function between `remove_hook` returning and `FreeLibrary` reclaiming the Logic DLL's `.text` pages will execute freed code; the resulting access violation often points at an address that no longer maps to anything, which is hard to triage from a crash dump. SafetyHook freezes game threads while it patches the original prologue back, but it has no visibility into worker threads spawned by the consumer.
1220+
`Bootstrap::on_logic_dll_unload(_all)` removes hooks and bindings, but it cannot prove that every consumer-owned worker thread has stopped firing those hooks. A worker thread that calls into a detoured function between `remove_hook` returning and `FreeLibrary` reclaiming the Logic DLL's `.text` pages will execute freed code; the resulting access violation often points at an address that no longer maps to anything, which is hard to triage from a crash dump. SafetyHook does not freeze threads while it patches the original prologue back; it only relocates a thread that faults on the patched page during the rewrite window, and has no visibility into worker threads spawned by the consumer, so a worker already inside a detour body is never rescued.
12201221

12211222
Stop and join every consumer-owned worker BEFORE you call the unload helper. The canonical Logic-DLL `Shutdown()` ordering for the persistent-host topology is:
12221223

@@ -1229,16 +1230,17 @@ extern "C" __declspec(dllexport) void Shutdown()
12291230
s_scan_worker.reset();
12301231
s_telemetry_worker.reset();
12311232

1232-
// 2. Now the only remaining callers into the hooks are game threads,
1233-
// which SafetyHook will freeze inside remove_hook. Drop the
1234-
// DLL-local registrations.
1233+
// 2. Now the only remaining callers into the hooks are game threads.
1234+
// SafetyHook does not freeze them inside remove_hook; it relocates
1235+
// only a thread that faults on the patched page during the rewrite,
1236+
// so they must be quiescent here. Drop the DLL-local registrations.
12351237
DMKBootstrap::on_logic_dll_unload(hook_names, binding_names);
12361238

12371239
// 3. Return from Shutdown(). The loader's FreeLibrary call follows.
12381240
}
12391241
```
12401242

1241-
A common worker case to watch for: a hook callback runs on a game thread, but a separate consumer-owned thread pool *also* calls into the same detour body (e.g. an off-thread snapshot capture, a deferred re-scan, a periodic poller that touches game state through a hooked accessor). Both paths must be quiet before the unload helper runs. If a worker calls into game-side code that the host module also hooks, joining the worker before unload is sufficient; SafetyHook handles the game-thread side.
1243+
A common worker case to watch for: a hook callback runs on a game thread, but a separate consumer-owned thread pool *also* calls into the same detour body (e.g. an off-thread snapshot capture, a deferred re-scan, a periodic poller that touches game state through a hooked accessor). Both paths must be quiet before the unload helper runs. If a worker calls into game-side code that the host module also hooks, joining the worker before unload handles the worker side; the game-thread side still depends on the hooked function being quiescent during removal, since SafetyHook only relocates threads that fault on the patched page and does not drain a thread already inside a detour.
12421244

12431245
---
12441246

include/DetourModKit/config.hpp

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,10 @@ namespace DetourModKit
195195
* @param log_key_name Human-readable name used in log output.
196196
* @param setter Callback applied with the resolved value. Must be reentrant and thread-safe.
197197
* @param default_value Value used when the key is absent or unparsable.
198+
* @note The INI is parsed as narrow bytes (the underlying SimpleIni uses SetUnicode(false)), so a value is
199+
* delivered to @p setter verbatim as the bytes on disk -- not transcoded. ASCII values (the common case)
200+
* pass through unchanged; a value with non-ASCII characters arrives as raw bytes (e.g. UTF-8 from a
201+
* UTF-8-saved INI), and any encoding interpretation is the consumer's responsibility.
198202
*/
199203
void register_string(std::string_view section, std::string_view ini_key, std::string_view log_key_name,
200204
std::function<void(const std::string &)> setter, std::string default_value);

include/DetourModKit/drift_manifest.hpp

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,11 @@ namespace DetourModKit
103103
* @param path Destination file path (UTF-8).
104104
* @param entries The drift entries to serialize.
105105
* @return true on success, false if the file could not be opened or written.
106+
* @note The write is not atomic: it truncates @p path in place, so a crash or power loss mid-write can leave a
107+
* partial manifest. That is acceptable here because the manifest is a regenerable diagnostic/diff
108+
* artifact (offsets are re-healed every session, never loaded as load-bearing state); a torn file is
109+
* reported as MalformedLine / MissingHeader on the next read and overwritten. Do not route load-bearing
110+
* data through this path without first making the write atomic (temp file + replace).
106111
*/
107112
[[nodiscard]] bool write_drift_report_to_file(const std::string &path, std::span<const DriftEntry> entries);
108113

include/DetourModKit/hook_manager.hpp

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -632,9 +632,13 @@ namespace DetourModKit
632632
* @param name A unique, descriptive name for the VMT hook.
633633
* @param object Pointer to the polymorphic object whose vptr will be replaced.
634634
* @return std::expected<std::string, HookError> The hook name if successful, error code otherwise.
635+
* @note Setup/control-plane only: clones a vtable, allocates, and takes the HookManager exclusive lock. Call
636+
* from init/shutdown or a worker thread, never from a hook or input callback.
635637
* @warning VMT hooks have no enable/disable: creation swaps the object's vptr to the cloned table and removal
636-
* restores it. As with the inline hooks, removal cannot drain a thread that is mid-dispatch through a
637-
* hooked slot, so the caller must guarantee no thread is calling a hooked method on @p object across
638+
* restores it. Removal is a bare vptr write with no thread protection at all -- weaker than inline/mid
639+
* teardown, which at least relocates a thread that faults on the patched page via SafetyHook's
640+
* vectored exception handler. A thread already dispatching through the cloned slot can call into the
641+
* freed clone, so the caller must guarantee no thread is calling a hooked method on @p object across
638642
* create/remove, and that @p object outlives the hook. The vptr must also stay stable for the hook's
639643
* lifetime; if the game reconstructs the object in place (rewriting its vptr) the hook is silently
640644
* lost.

include/DetourModKit/logger.hpp

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,10 @@ namespace DetourModKit
189189
* lost: the post-join drain can miss at most one in-flight message per producer thread (an accepted
190190
* trade-off documented on AsyncLogger::shutdown). Do not rely on a final diagnostics line reaching the
191191
* file if it is emitted while the logger is being torn down.
192+
* @note In synchronous mode a Warning or Error force-flushes the file stream under the log mutex, so a hook or
193+
* input callback that logs at those levels every frame stalls the game thread on disk I/O. For per-frame
194+
* hot-path logging, enable_async_mode() first (the lock-free queue is non-blocking and callback-safe), or
195+
* keep the hot path at Debug/Trace, which is gated out unless explicitly enabled.
192196
*/
193197
bool log(LogLevel level, std::string_view message);
194198

include/DetourModKit/rtti_dissect.hpp

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,11 @@ namespace DetourModKit
341341
* - @ref HealError::NoMatch when no slot matched;
342342
* - @ref HealError::Ambiguous when both the @c +d and @c -d slots
343343
* at the nearest matching distance match (an irreducible tie).
344+
* @note For a struct known to hold more than one field of @c expected_mangled's type, prefer @ref
345+
* solve_fingerprint. A single landmark resolves to the uniquely nearest same-typed slot, so a nearer
346+
* same-typed neighbour heals to the wrong field silently (both satisfy the slot shape); the @ref
347+
* HealError::Ambiguous result fires only for an exact +/- distance tie, not for a nearer decoy.
348+
* solve_fingerprint disambiguates structurally because one uniform delta must fit every field at once.
344349
* @warning Init-time / re-heal-on-miss, not per-frame: each probe runs the syscall-heavy prelude up to twice.
345350
* The window cap bounds the worst case. Allocates nothing (one reused stack @ref PointeeType).
346351
*/
@@ -398,6 +403,10 @@ namespace DetourModKit
398403
* required landmark;
399404
* - @ref HealError::Ambiguous when two or more deltas tie for the
400405
* most optional matches.
406+
* @note Each landmark in @p fp must have a distinct @c nominal_offset. Corroboration is scored by counting the
407+
* required landmarks satisfied at a delta, so two landmarks sharing a nominal_offset probe the same slot
408+
* and would double-count it, reporting stronger agreement than the template provides. Distinct offsets
409+
* are an author-side invariant of the constexpr template, not validated at runtime.
401410
* @warning Init-time only: the probe count is (2 * window_bytes / 8 + 1) * fp.size() prelude walks. Allocates
402411
* nothing.
403412
*/

include/DetourModKit/scanner.hpp

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -641,6 +641,13 @@ namespace DetourModKit
641641
* RipRelative candidates are skipped in the fallback phase since they target instructions deeper than
642642
* the patched prologue and are unaffected by the overwrite.
643643
*
644+
* @note Recovery covers only the E9 near-jump and FF25 indirect-jump trampoline shapes, and never returns a
645+
* wrong address. Two failure modes are distinct and worth handling separately: NoMatch means the direct
646+
* scan and the rebuilt E9/FF25 fallback both ran and matched nothing (the case for a prologue overwritten
647+
* by an unhandled shape such as a push imm32 / ret thunk, an FF15 call thunk, or a prefixed jump);
648+
* PrologueFallbackNotApplicable means no fallback could be formed in the first place (a Direct-mode
649+
* candidate's literal tail was too short to rebuild a unique pattern around the prologue), so nothing was
650+
* retried. Do not assume every unsupported overwrite collapses to NoMatch.
644651
* @param candidates Ordered candidates.
645652
* @param label Human-readable identifier used in log messages.
646653
* @return ResolveHit on success; ResolveError on failure.

0 commit comments

Comments
 (0)