Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@ Do not add `Memory::is_readable()` or `Memory::is_writable()` before every field

### Scanning process memory

The raw `Scanner::find_pattern(start_address, region_size, pattern)` overloads do no page filtering: they read the whole span with `memchr`/SIMD, so the caller must guarantee `[start_address, start_address + region_size)` is committed and readable, or the host faults. Use them only on byte buffers or module sections whose readability is already known. To scan arbitrary process or module memory, prefer the page-filtered helpers (`scan_executable_regions`, `scan_readable_regions`), which walk `VirtualQuery` and skip guard, no-access, and non-readable pages. The per-region `VirtualQuery` gate proves readability only at gate time; on MSVC each region read additionally runs inside a structured-exception guard, so a region decommitted or reprotected concurrently between the gate and the read is skipped (and counted at `Debug`) rather than faulting the host. On MinGW the gate is the only guard, so that narrow TOCTOU window can still fault until the VEH-based handler lands. The same guard wraps the per-window reads behind `find_string_xref`.
The raw `Scanner::find_pattern(start_address, region_size, pattern)` overloads do no page filtering: they read the whole span with `memchr`/SIMD, so the caller must guarantee `[start_address, start_address + region_size)` is committed and readable, or the host faults. Use them only on byte buffers or module sections whose readability is already known. To scan arbitrary process or module memory, prefer the page-filtered helpers (`scan_executable_regions`, `scan_readable_regions`), which walk `VirtualQuery` and skip guard, no-access, and non-readable pages. The per-region `VirtualQuery` gate proves readability only at gate time; on MSVC each region read additionally runs inside a structured-exception guard, so a region decommitted or reprotected concurrently between the gate and the read is skipped (and counted at `Debug`) rather than faulting the host. On MinGW the bulk `find_pattern_raw` reads are not covered by the `seh_read` vectored fault guard (that guard wraps the copy primitives, not the SIMD sweep), so the per-region gate is the only guard there and that narrow TOCTOU window can still fault. The same guard wraps the per-window reads behind `find_string_xref`.

## Testing

Expand Down Expand Up @@ -378,10 +378,10 @@ These are called at 60+ fps from game hook callbacks. Never add allocations, exc
- `Logger::log()` async enqueue -- atomic shared_ptr load + lock-free queue push
- `Memory::is_readable()` -- sharded SRWLOCK reader + cache lookup
- `Memory::is_readable_nonblocking()` -- try_lock_shared + cache lookup (returns Unknown on contention)
- `Memory::read_ptr_unsafe()` -- SEH-protected raw dereference (MSVC), cache-accelerated with VirtualQuery fallback (MinGW)
- `Memory::read_ptr_unsafe()` -- SEH-protected raw dereference (MSVC), vectored-handler-guarded read on MinGW (no per-call VirtualQuery; the fault guard also closes the stale-cache dereference)
- `Memory::read_ptr_unchecked()` -- inline pointer dereference with source and result user-mode range guards (a low-address floor plus a `USERSPACE_PTR_MAX` ceiling that rejects kernel-range and non-canonical values, which also subsumes pointer-arithmetic wraparound), no SEH (caller must guarantee structural pointer validity); debug builds add an `is_readable` assert that catches a stale or unmapped source pointer, compiled out in release so the hot path stays a single guarded memcpy
- `Memory::seh_read<T>()` / `seh_read_bytes()` -- typed and raw SEH-guarded reads; single `__try` frame on MSVC, VirtualQuery loop across regions on MinGW. The MSVC `__except` filter swallows the foreign-read fault set -- `EXCEPTION_ACCESS_VIOLATION`, `STATUS_GUARD_PAGE_VIOLATION`, and `EXCEPTION_IN_PAGE_ERROR` (a file-backed or image-mapped page failing to page in, e.g. during an RTTI / section walk) -- and lets any other code continue the handler search; the predicate (`Memory::detail::is_guarded_read_fault`) is shared by every guarded read so the set stays uniform. Used by `Rtti` for chained RTTI walks
- `Memory::seh_resolve_chain()` / `seh_read_chain<T>()` -- resolves or reads a whole multi-level pointer chain under one fault guard: one out-of-line call instead of N separate `seh_read` calls, with each intermediate link kept in a register and pre-screened by `plausible_userspace_ptr` (a faulting or implausible link aborts the walk and returns nullopt/false). VirtualQuery-guarded per link on MinGW
- `Memory::seh_read<T>()` / `seh_read_bytes()` -- typed and raw SEH-guarded reads; single `__try` frame on MSVC, and on MinGW a single `rep movsb` copy under a process-wide vectored exception handler (installed lazily / by `init_cache`, removed by `shutdown_cache`) that recovers via a non-unwinding `__builtin_setjmp`/`__builtin_longjmp`, so the success path runs no syscall. Both toolchains swallow the same foreign-read fault set -- `EXCEPTION_ACCESS_VIOLATION`, `STATUS_GUARD_PAGE_VIOLATION`, and `EXCEPTION_IN_PAGE_ERROR` (a file-backed or image-mapped page failing to page in, e.g. during an RTTI / section walk) -- via the shared predicate `Memory::detail::is_guarded_read_fault`, and let any other fault continue the handler search; the MinGW handler additionally claims only faults whose address lies in the foreign range being read. A guarded read uses a single per-thread guard, so it must not nest on one thread (DMK reads are synchronous, so it does not); if `AddVectoredExceptionHandler` ever fails the reads fall back to VirtualQuery validation. Used by `Rtti` for chained RTTI walks
- `Memory::seh_resolve_chain()` / `seh_read_chain<T>()` -- resolves or reads a whole multi-level pointer chain under one fault guard: one out-of-line call instead of N separate `seh_read` calls, with each intermediate link kept in a register and pre-screened by `plausible_userspace_ptr` (a faulting or implausible link aborts the walk and returns nullopt/false). On MinGW each link read is guarded by the vectored handler
- `Memory::plausible_userspace_ptr(p)` -- `inline constexpr` user-mode pointer plausibility test; pure arithmetic with no syscall and no memory access (early-rejects stale/sentinel/torn pointers before an SEH-guarded read)
- `Memory::contains(range, p)` -- constexpr point-in-range test for module bounds checks
- `Memory::own_module_range()` / `host_module_range()` -- magic-static cached, single atomic load on the fast path
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ See the [Config Hot-Reload Guide](docs/config-hot-reload/README.md) for the thre
- Functions for checking memory readability/writability and writing bytes to memory
- Optional memory region cache with sharded SRWLOCK concurrency, LRU eviction, and stampede coalescing
- `is_readable_nonblocking()` - tri-state (readable/not-readable/unknown) for latency-sensitive threads
- `read_ptr_unsafe()` - safe pointer reads in hot paths (SEH-protected on MSVC, cache-accelerated with VirtualQuery fallback on MinGW)
- `read_ptr_unsafe()` - safe pointer reads in hot paths (SEH-protected on MSVC, guarded by a process-wide vectored exception handler on MinGW, so the success path issues no per-call VirtualQuery)
- `read_ptr_unchecked()` - inline header-only variant with a configurable lower-bound guard plus a `USERSPACE_PTR_MAX` ceiling for pointer chain traversal without per-call SEH overhead (caller must guarantee structural pointer validity)
- `seh_read<T>()` / `seh_read_bytes()` - typed SEH-guarded reads for arbitrary trivially copyable T (and contiguous byte ranges), used to walk torn pointer chains and parse PE headers without per-site `__try` boilerplate. Returns `std::optional<T>` / `bool` so callers can distinguish "read faulted" from "read returned zero"
- `module_range_for(addr)` / `own_module_range()` / `host_module_range()` - PE image range queries (base + SizeOfImage) for sanity-checking that a resolved vtable or return address lives inside the game image vs the heap or an injected DLL. Per-HMODULE cache for `module_range_for`; magic-static cache for the own and host variants
Expand Down
60 changes: 60 additions & 0 deletions docs/analysis/memory_veh_bench_v3.x/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Memory microbenchmark: MinGW vectored-handler fault guard

This directory captures a run of `tests/bench_memory.cpp` against the `Memory` read primitives on **MinGW**, where the `seh_read` family is guarded by a process-wide vectored exception handler (VEH) rather than a per-call `VirtualQuery`. It is the MinGW companion to the MSVC numbers in [../memory_bench_v3.x](../memory_bench_v3.x): the same harness, run once per toolchain, so the two columns below are directly comparable.

The question is "does the VEH actually buy anything on MinGW?" The VirtualQuery-validated baseline issues a `VirtualQuery` syscall on every terminal read (and one per link on a chain walk) because GCC has no zero-cost frame-based SEH. The VEH path removes that syscall entirely: the success path is a single `rep movsb` under a thread-local guard, and a read fault is recovered with a non-unwinding `__builtin_setjmp` / `__builtin_longjmp` (the GCC equivalent of Frida's `_setjmp(env, NULL)`). The per-thread guard is published through a Win32 TLS slot read with `TlsGetValue`, which is allocation-free and safe to touch from the exception-dispatch context (a `thread_local` / `__thread` would lower to `__emutls_get_address`, which allocates and locks on first access -- forbidden in a handler).

The benchmark measures the per-call cost of each read primitive. The two numbers that matter are:

- **`seh_read<u64>`** -- a single terminal guarded read. The MinGW VirtualQuery baseline pays one query (the `raw VirtualQuery` row) plus a copy; the VEH path pays neither.
- **`seh_resolve_chain` / `seh_read_chain<u64>`** -- a six-link pointer-chain walk. The VirtualQuery-validated baseline pays one query per link.

## Hardware / configuration

- Build: `cmake --preset mingw-release -DDMK_BUILD_BENCHMARKS=ON` (MinGW column); MSVC Release `/O2` with `-DDMK_BUILD_BENCHMARKS=ON` (MSVC column)
- Toolchains: GCC 15.1 (MSYS2 MinGW-w64), `-O3` + LTO, `seh_*` use the vectored-handler fault guard; MSVC 2022, `seh_*` use real `__try` / `__except` (table-driven on x64, free on the no-fault path)
- Iterations: 200,000 per sample (20,000 for `write_bytes`); 15 samples; median reported
- Pointer chain: six links, warm cache
- `DEFAULT_CACHE_EXPIRY_MS = 50`

## Results

```text
primitive MinGW (VEH) MSVC (__try) MinGW VirtualQuery baseline
raw VirtualQuery 224.28 ns 213.76 ns -- (this row is the per-read baseline)
direct volatile load 6.47 ns ~4 ns --
read_ptr_unchecked 191.82 ns 5.34 ns -- (range-guarded, no fault guard; debug-assert build)
seh_read<u64> 62.34 ns 7.26 ns ~224 ns (a VirtualQuery + copy)
seh_resolve_chain (6) 250.15 ns 9.92 ns ~1345 ns (6 x VirtualQuery)
seh_read_chain<u64> (6) 299.26 ns 8.94 ns ~1345 ns (6 x VirtualQuery + terminal read)
```

The "MinGW VirtualQuery baseline" column is derived, not separately benchmarked: `virtualquery_validated_copy` is the install-failure fallback, so its cost is the `raw VirtualQuery` row (224 ns) times the number of regions a read spans -- one for a terminal read, one per link for a chain.

## How to read this

| Primitive | MinGW VEH | vs VirtualQuery baseline | vs MSVC `__try` |
|-----------|-----------|-------------------|-----------------|
| `seh_read<u64>` | 62 ns | **~3.6x faster** (no VirtualQuery) | ~8.6x slower |
| `seh_resolve_chain` (6 links) | 250 ns | **~5.4x faster** (no per-link syscall) | ~25x slower |
| `seh_read_chain<u64>` (6 links) | 299 ns | **~4.5x faster** | ~33x slower |

A few takeaways:

1. **The VEH eliminates the syscall, which is the whole point.** A guarded terminal read dropped from a `VirtualQuery` (224 ns) to 62 ns, and a six-link chain from roughly 1.3 microseconds of syscalls to 250 ns. The remaining MinGW cost is the `__builtin_setjmp` snapshot, two `TlsSetValue` writes, the drain-epoch atomics, and a `rep movsb` -- all userspace, no kernel transition. The 62 ns being far below a single `raw VirtualQuery` is also the proof that the VEH is actually active rather than silently falling back.
2. **The MinGW-to-MSVC gap is inherent and not closable.** MSVC `__try` is table-driven SEH described by `.pdata`/`.xdata` emitted at compile time, so the no-fault path costs nothing beyond the read itself (7-10 ns). GCC on Windows has no equivalent; any runtime fault guard -- VEH, signal handler, or otherwise -- must do per-read setup. So the realistic goal was "stop paying a syscall per read," which is met, not "match MSVC."
3. **The hardened TLS path keeps the handler allocation-free.** Publishing the guard through a Win32 TLS slot (`TlsSetValue` / `TlsGetValue`, a TEB-array access) avoids the `__emutls_get_address` call a mingw `thread_local` lowers to, and that matters because allocation and locking are forbidden in the exception-dispatch context.
4. **`read_ptr_unchecked` stays the floor for known-live pointers.** It applies only range guards and no fault guard; in this debug-assert benchmark build its MinGW number is inflated by the `is_readable` assert, but in a release consumer it is a single guarded `memcpy`. Use it only when the caller can prove the pointer is live this frame; otherwise `seh_read` / `seh_read_chain` is the safe choice and, on MinGW, now cheap enough to use freely.

## Reproducing locally

```bash
# MinGW (vectored-handler path)
PATH="/c/msys64/mingw64/bin:$PATH" \
cmake -S . -B build/mingw-release -DDMK_BUILD_BENCHMARKS=ON
PATH="/c/msys64/mingw64/bin:$PATH" \
cmake --build build/mingw-release --target DetourModKit_bench_memory -j 3
./build/mingw-release/tests/DetourModKit_bench_memory.exe
```

The program prints the human-readable tables shown above plus a `#TSV` block on stdout for scripted comparison. The toolchain banner on the first line confirms which fault-guard path is in effect (`vectored-handler fault guard` on MinGW, `__try/__except` on MSVC).
6 changes: 3 additions & 3 deletions docs/misc/hot-path-memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ bool probe_object(uintptr_t obj, ObjectFields &out) noexcept
}

// 2. Read every field under a single SEH-guarded chain walk. On MSVC this
// is one __try frame; on MinGW it is VirtualQuery-guarded. Either way a
// fault anywhere in the walk returns nullopt instead of crashing.
// is one __try frame; on MinGW it uses the vectored fault guard. Either
// way a fault anywhere in the walk returns nullopt instead of crashing.
const auto vtable = Mem::seh_read<uintptr_t>(obj);
if (!vtable || !Mem::contains(g_host, *vtable))
{
Expand Down Expand Up @@ -116,7 +116,7 @@ if (value)

## Toolchain note

The `seh_*` primitives use real `__try` / `__except` on MSVC, where the success path is table-driven and costs nothing extra. On MinGW (which has no SEH) they fall back to a `VirtualQuery`-guarded read, which is correct but pays a syscall; prefer `read_ptr_unchecked` on MinGW hot paths where you can guarantee structural validity. Shipping mod builds target MSVC, so the zero-cost path is the normal case.
The `seh_*` primitives use real `__try` / `__except` on MSVC, where the success path is table-driven and costs nothing extra. On MinGW (which has no frame-based SEH) a 64-bit build installs a process-wide vectored exception handler once and runs the read as a guarded copy with no `VirtualQuery` on the success path, recovering a fault as `std::nullopt` / `false` instead of crashing. This is best-effort: `veh_read_bytes` takes the VEH guarded-copy path only when handler installation succeeded (`s_veh_handle` is non-null); if `ensure_veh_installed()` failed, or on a 32-bit MinGW build (`!_WIN64`), it falls back to `virtualquery_validated_copy`, a `VirtualQuery`-validated copy that pays a syscall per region. So MinGW `seh_*` reads are fault-safe either way, using the VEH when available and the VirtualQuery fallback otherwise. `read_ptr_unchecked` is still the fastest choice when you can prove the pointer is live for the current frame; otherwise prefer `seh_read` / `seh_read_chain` for stale or unmapped pointers. Shipping mod builds target MSVC, so the zero-cost path is the normal case.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
## Anti-patterns to remove

Expand Down
4 changes: 2 additions & 2 deletions docs/misc/rtti-walker.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ Scoping to one `ModuleRange` (the default is the host EXE) is load-bearing for c

## Performance notes

- The walker issues two SEH-guarded reads per call on the cold path: one for the COL pointer at `vtable - 8`, one batched read of the 24-byte `ColHead`. On MSVC each `__try` frame is essentially free on the success path. On MinGW each read goes through `VirtualQuery`, which is microseconds-class; the batched ColHead read keeps the MinGW cost down to two syscalls instead of four.
- The walker issues two SEH-guarded reads per call on the cold path: one for the COL pointer at `vtable - 8`, one batched read of the 24-byte `ColHead`. On MSVC each `__try` frame is essentially free on the success path. On MinGW each read uses the vectored fault guard, so the success path avoids the per-read `VirtualQuery` syscall; the batched ColHead read still matters because it keeps the walker to two guarded calls instead of four.
- `vtable_is_type` reads `expected.size() + 1` name bytes in a single SEH frame and compares with `memcmp`. There is no heap allocation, no string construction, and no demangle pass.
- `type_name_of` allocates one `std::string` per call. Prefer `type_name_into` or `vtable_is_type` on hot paths.
- `find_in_pointer_table` on a cold cache scans every non-null slot with the full walker. With a warm cache it touches each slot exactly once with a qword compare. For a sparse table of 256 slots and a unique target, the warm-path cost is dominated by the slot dereference, not the RTTI machinery.
Expand All @@ -154,6 +154,6 @@ None of these raise an exception; the caller can treat all failure modes uniform

## MinGW support

The walker works correctly on both MSVC and MinGW builds of DetourModKit when targeting MSVC-compiled binaries (the typical use case for game mods). The underlying `Memory::seh_read_bytes` primitive uses `__try` / `__except` on MSVC and a `VirtualQuery`-based region validation loop on MinGW. The MinGW path is race-prone against concurrent `VirtualProtect` and slower (one syscall per region), but produces identical results for stable game state.
The walker works correctly on both MSVC and MinGW builds of DetourModKit when targeting MSVC-compiled binaries (the typical use case for game mods). The underlying `Memory::seh_read_bytes` primitive uses `__try` / `__except` on MSVC and the process-wide vectored fault guard on MinGW. Both toolchains fail closed on unreadable RTTI pages and produce identical results for stable game state; MSVC remains faster because its x64 SEH tables add no success-path setup.

Note: the walker reads the MSVC RTTI ABI. If the target object was compiled by GCC or Clang for the Itanium C++ ABI, the layout at `vtable - 8` is different and the walker will fail. This is by design; DetourModKit consumers building mods for MSVC-compiled games (every major Windows game engine since 2010) will not encounter Itanium RTTI in their target processes.
Loading
Loading