GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 91.7% 99 / 0 / 108
Functions: 100.0% 27 / 0 / 27
Branches: 81.5% 44 / 0 / 54

include/DetourModKit/memory.hpp
Line Branch Exec Source
1 #ifndef DETOURMODKIT_MEMORY_HPP
2 #define DETOURMODKIT_MEMORY_HPP
3
4 #include <array>
5 #include <bit>
6 #include <cassert>
7 #include <cstddef>
8 #include <cstdint>
9 #include <cstring>
10 #include <expected>
11 #include <initializer_list>
12 #include <memory>
13 #include <optional>
14 #include <span>
15 #include <string>
16 #include <string_view>
17 #include <type_traits>
18
19 namespace DetourModKit
20 {
21 /**
22 * @enum MemoryError
23 * @brief Error codes for memory operation failures.
24 */
25 enum class MemoryError
26 {
27 NullTargetAddress,
28 NullSourceBytes,
29 SizeTooLarge,
30 ProtectionChangeFailed,
31 ProtectionRestoreFailed
32 };
33
34 /**
35 * @brief Converts a MemoryError to a human-readable string.
36 * @param error The error code.
37 * @return A string view describing the error.
38 */
39 6 [[nodiscard]] constexpr std::string_view memory_error_to_string(MemoryError error) noexcept
40 {
41
6/6
✓ Branch 2 → 3 taken 1 time.
✓ Branch 2 → 4 taken 1 time.
✓ Branch 2 → 5 taken 1 time.
✓ Branch 2 → 6 taken 1 time.
✓ Branch 2 → 7 taken 1 time.
✓ Branch 2 → 8 taken 1 time.
6 switch (error)
42 {
43 1 case MemoryError::NullTargetAddress:
44 1 return "Target address is null";
45 1 case MemoryError::NullSourceBytes:
46 1 return "Source bytes pointer is null";
47 1 case MemoryError::SizeTooLarge:
48 1 return "Write size exceeds maximum allowed";
49 1 case MemoryError::ProtectionChangeFailed:
50 1 return "Failed to change memory protection";
51 1 case MemoryError::ProtectionRestoreFailed:
52 1 return "Failed to restore original memory protection";
53 1 default:
54 1 return "Unknown memory error";
55 }
56 }
57
58 /// Maximum write size for write_bytes (64 MiB).
59 inline constexpr size_t MAX_WRITE_SIZE = 64 * 1024 * 1024;
60
61 /// Default number of region entries the memory cache holds.
62 inline constexpr size_t DEFAULT_CACHE_SIZE = 256;
63 /// Default cache entry lifetime, in milliseconds, before a re-query.
64 inline constexpr unsigned int DEFAULT_CACHE_EXPIRY_MS = 50;
65 /// Minimum permitted cache size.
66 inline constexpr size_t MIN_CACHE_SIZE = 1;
67 /// Default number of cache shards, striped to reduce reader contention.
68 inline constexpr size_t DEFAULT_CACHE_SHARD_COUNT = 16;
69 /// Default multiplier bounding the cache's maximum size relative to its configured size.
70 inline constexpr size_t DEFAULT_MAX_CACHE_SIZE_MULTIPLIER = 2;
71
72 namespace Memory
73 {
74 /**
75 * @brief Initializes the memory region cache with specified parameters.
76 * @details Sets up an internal cache to store information about memory regions, reducing overhead of frequent
77 * VirtualQuery system calls.
78 * @param cache_size The desired number of entries in the cache. Defaults to 256.
79 * @param expiry_ms Cache entry expiry time in milliseconds. Defaults to 50ms.
80 * @param shard_count Number of cache shards for concurrent access. Defaults to 16.
81 * @return true if the cache is ready for use (newly or previously initialized), false on failure.
82 * @note Only the first call configures the cache; subsequent calls return true without reconfiguring. To
83 * reconfigure, call shutdown_cache() first.
84 * @note Starts a background cleanup thread that runs periodically.
85 */
86 [[nodiscard]] bool init_cache(size_t cache_size = DEFAULT_CACHE_SIZE,
87 unsigned int expiry_ms = DEFAULT_CACHE_EXPIRY_MS,
88 size_t shard_count = DEFAULT_CACHE_SHARD_COUNT);
89
90 /**
91 * @brief Clears all entries from the memory region cache.
92 * @details Invalidates all currently cached memory region information. The background cleanup thread continues
93 * running.
94 */
95 void clear_cache() noexcept;
96
97 /**
98 * @brief Shuts down the memory cache and stops the background cleanup thread.
99 * @details Call this before program exit to cleanly terminate the cleanup thread. After calling shutdown, the
100 * cache cannot be reused without re-initialization.
101 */
102 void shutdown_cache() noexcept;
103
104 /**
105 * @struct MemoryStats
106 * @brief Allocation-free snapshot of memory-cache configuration and counters.
107 * @details Every field mirrors a value reported by get_cache_stats(). Counters are loaded with relaxed
108 * atomics and the live-entry totals are summed under the shard reader guard, so the struct is a
109 * consistent-per-field but not globally-atomic view. hit_rate_percent is -1.0 when no queries have
110 * been tracked (hits + misses == 0), matching the formatter's "N/A" branch.
111 */
112 struct MemoryStats
113 {
114 /// Configured number of cache shards.
115 size_t shard_count = 0;
116 /// Configured soft entry capacity per shard.
117 size_t max_entries_per_shard = 0;
118 /// Hard max entries per shard (capacity * multiplier), averaged across shards as the string reports it.
119 size_t hard_max_per_shard = 0;
120 /// Cache-entry expiry window in milliseconds.
121 unsigned int expiry_ms = 0;
122 /// Cumulative cache hits.
123 uint64_t hits = 0;
124 /// Cumulative cache misses.
125 uint64_t misses = 0;
126 /// Cumulative range invalidations.
127 uint64_t invalidations = 0;
128 /// Cumulative in-flight query coalesces.
129 uint64_t coalesced_queries = 0;
130 /// Cumulative on-demand cleanup passes.
131 uint64_t on_demand_cleanups = 0;
132 /// Live entry count summed across all shards at snapshot time.
133 size_t total_entries = 0;
134 /// hits / (hits + misses) * 100, or -1.0 when no queries have been tracked.
135 double hit_rate_percent = -1.0;
136 };
137
138 /**
139 * @brief Returns an allocation-free snapshot of memory-cache statistics.
140 * @details Value-returning counterpart to get_cache_stats(): reads the same counters and walks the shards
141 * under the same reader guard, but builds no string. Prefer it for telemetry/metrics consumers, and
142 * use get_cache_stats() for human-readable log lines.
143 * @return MemoryStats POD snapshot.
144 */
145 [[nodiscard]] MemoryStats get_memory_stats() noexcept;
146
147 /**
148 * @brief Retrieves statistics about the memory cache usage.
149 * @details Returns cache hits, misses, and hit rate information as a human-readable string built over
150 * get_memory_stats().
151 * @return std::string A human-readable string of cache statistics.
152 */
153 [[nodiscard]] std::string get_cache_stats();
154
155 /**
156 * @brief Invalidates cache entries that overlap with the specified address range.
157 * @details Used to force re-query of memory region info after external changes such as VirtualProtect calls by
158 * other code.
159 * @param address Starting address of the range to invalidate.
160 * @param size Size of the range to invalidate.
161 */
162 void invalidate_range(const void *address, size_t size);
163
164 /**
165 * @brief Checks if a specified memory region is readable.
166 * @details Verifies if the memory range has read permissions and is committed.
167 * @param address Starting address of the memory region.
168 * @param size Number of bytes in the memory region to check.
169 * @return true if the entire region is readable, false otherwise.
170 * @warning Not a per-dereference gate for hot paths. A cache hit still takes a shard reader lock and a cache
171 * miss issues a
172 * VirtualQuery syscall; placing this in front of every field read on a per-frame or per-object path
173 * multiplies that cost by the read count and is dominated by cache misses when the target addresses
174 * change. It is also a time-of-check to time-of-use illusion: the page state can change between the
175 * check and the access. For hot-path reads of game-owned pointers, drop the predicate and read
176 * directly under a single
177 * SEH frame (@ref seh_read, @ref seh_read_chain), optionally pre-screened by @ref
178 * plausible_userspace_ptr and a module or heap range check. Reserve is_readable for one-shot setup
179 * validation and diagnostics.
180 */
181 [[nodiscard]] bool is_readable(const void *address, size_t size);
182
183 /**
184 * @enum ReadableStatus
185 * @brief Tri-state result for non-blocking readability checks.
186 */
187 enum class ReadableStatus
188 {
189 Readable,
190 NotReadable,
191 Unknown
192 };
193
194 /**
195 * @brief Non-blocking readability check that avoids contention on shared locks.
196 * @details Non-blocking only once init_cache() has published the cache: it tries a shared try-lock on the
197 * owning shard and answers from the cached protection on a hit. It returns Unknown -- so a
198 * latency-sensitive caller can fall back to an SEH-guarded read instead of stalling -- in three
199 * cases: the shard try-lock is contended, the shard holds no live entry for the range (a cache
200 * miss; it does not issue a blocking VirtualQuery here), or the cache is mid-publication (the
201 * initialized flag is set but the shard array is not yet visible). Before init_cache() (or after
202 * shutdown_cache()) there is no cache to consult, so it falls back to a single blocking
203 * VirtualQuery and returns a definite Readable or NotReadable, never Unknown.
204 * @param address Starting address of the memory region.
205 * @param size Number of bytes in the memory region to check.
206 * @return ReadableStatus: Readable, NotReadable, or Unknown (shard lock busy, cache miss, or the
207 * init-publication window; Unknown is only returned once the cache is initialized).
208 */
209 [[nodiscard]] ReadableStatus is_readable_nonblocking(const void *address, size_t size);
210
211 /**
212 * @brief Reads a pointer-sized value at (base + offset), returning 0 on fault.
213 * @details On MSVC, uses SEH (__try/__except) to catch access violations with zero overhead on the success
214 * path. On MinGW, the read runs under a process-wide vectored exception handler (installed lazily), so
215 * the success path is a guarded copy with no syscall and any fault -- unmapped,
216 * PAGE_NOACCESS/guard, or a page reprotected out from under a stale cache entry -- is swallowed and
217 * returned as 0. Suitable for hot paths that already manage their own error recovery.
218 * @note The MinGW path consults no cache; the fault guard makes a cache probe unnecessary. If the vectored
219 * handler cannot be installed it falls back to VirtualQuery plus ReadProcessMemory. Either way the
220 * function exposes no caller-observable cache state.
221 * @param base The base address to read from.
222 * @param offset Byte offset added to base before dereferencing.
223 * @return The pointer-sized value at the address, or 0 if the read faults.
224 */
225 [[nodiscard]] uintptr_t read_ptr_unsafe(uintptr_t base, ptrdiff_t offset) noexcept;
226
227 /**
228 * @brief Inclusive lower bound of the canonical x64 user-mode address window.
229 * @details The low 64 KiB is the reserved null-dereference region and is never a live pointer, so any value
230 * below this bound cannot be a valid object pointer. Paired with @ref USERSPACE_PTR_MAX, the window
231 * rejects stale or sentinel values with no syscall and no memory access.
232 */
233 inline constexpr uintptr_t USERSPACE_PTR_MIN = 0x10000;
234
235 /**
236 * @brief Exclusive upper bound of the canonical x64 user-mode address window.
237 * @details Mapped user addresses sit below the 47-bit canonical split at 0x0000'8000'0000'0000; a value at or
238 * above this bound is a kernel-range or non-canonical address and cannot be a valid user-mode object
239 * pointer. Paired with @ref USERSPACE_PTR_MIN.
240 */
241 inline constexpr uintptr_t USERSPACE_PTR_MAX = 0x0000800000000000ULL;
242
243 /**
244 * @brief Structural plausibility test for an x64 user-mode pointer.
245 * @details Returns true only when @p p lies in
246 * [@ref USERSPACE_PTR_MIN, @ref USERSPACE_PTR_MAX). This is a pure arithmetic guard with no memory
247 * access and no syscall, intended to terminate pointer-chain traversals early on obviously bad values
248 * (null, small enum-shaped integers, non-canonical addresses) before paying for an SEH-guarded read.
249 * It does not prove the pointer is mapped or that the target object is the expected type; pair it with
250 * a module or heap range check and an SEH-guarded read for full validation.
251 * @param ptr The pointer value to test.
252 * @return true if @p ptr is a plausible user-mode pointer, false otherwise.
253 */
254 331 [[nodiscard]] inline constexpr bool plausible_userspace_ptr(uintptr_t ptr) noexcept
255 {
256
4/4
✓ Branch 2 → 3 taken 322 times.
✓ Branch 2 → 5 taken 9 times.
✓ Branch 3 → 4 taken 320 times.
✓ Branch 3 → 5 taken 2 times.
331 return ptr >= USERSPACE_PTR_MIN && ptr < USERSPACE_PTR_MAX;
257 }
258
259 /**
260 * @brief Fastest pointer dereference with user-mode range validity guards only.
261 * @details Validates the source address (base + offset) before dereferencing, then applies the same guard to
262 * the loaded value. Both the source and the result must sit in the user-mode window (min_valid, @ref
263 * USERSPACE_PTR_MAX): the floor rejects the null page and guard pages (addresses below 0x10000 are
264 * never valid usermode pointers on Windows), and the ceiling rejects kernel-range and non-canonical
265 * addresses. Both checks terminate stale/dangling pointer chain traversals early without requiring an
266 * SEH frame.
267 *
268 * This function does NOT provide fault protection against unmapped or freed memory above min_valid. If
269 * the pointer chain may reference deallocated heap memory or unmapped regions, use read_ptr_unsafe()
270 * instead (SEH-protected on MSVC, vectored-handler-guarded on MinGW).
271 *
272 * The "unchecked" in the name refers to the absence of OS-level memory validation in release builds
273 * (no SEH, no VirtualQuery, no cache lookup). Only user-mode range guards are applied. Intended for
274 * hot paths where the caller can guarantee structural pointer validity (e.g. game objects known to be
275 * alive this frame).
276 * @note Debug builds (NDEBUG undefined) add an assert that the source is readable, so passing a stale or
277 * unmapped pointer is caught during development instead of faulting the host. The probe is compiled out
278 * in release, leaving the hot path a single guarded memcpy. Use @ref seh_read_chain or @ref
279 * read_ptr_unsafe for pointers that may not be alive.
280 * @param base The base address to read from.
281 * @param offset Byte offset added to base before dereferencing.
282 * @param min_valid Minimum address value to accept (default 0x10000).
283 * @return The pointer-sized value at the address, or 0 if either the source address or the dereferenced value
284 * falls outside the user-mode window (min_valid, @ref USERSPACE_PTR_MAX).
285 * @note The lower bound is exclusive here, whereas plausible_userspace_ptr treats the same bound as inclusive.
286 * The difference is intentional:
287 * this function performs an unguarded dereference, so it is one address more conservative and never
288 * blindly reads the boundary itself, while plausible_userspace_ptr only screens a value arithmetically.
289 */
290 19 [[nodiscard]] inline uintptr_t read_ptr_unchecked(uintptr_t base, ptrdiff_t offset,
291 uintptr_t min_valid = 0x10000) noexcept
292 {
293 19 const auto src = base + static_cast<uintptr_t>(offset);
294 // Accept the source only strictly inside the user-mode window (min_valid, USERSPACE_PTR_MAX). The floor is
295 // deliberately exclusive: this dereference is unguarded in release, so min_valid itself is treated as the
296 // first address NOT trusted for a blind read (one address more conservative than plausible_userspace_ptr,
297 // which is a pure no-deref pre-screen and so is inclusive at the same bound). The ceiling rejects
298 // kernel-range and non-canonical sources; together with the floor it also subsumes any pointer-arithmetic
299 // wraparound, because a ptrdiff_t offset is too small to carry base back into this window (a non-negative
300 // offset cannot overflow past 2^64 from a userspace base, and an underflowing negative offset lands either
301 // below min_valid or above the ceiling), so no separate wrap check is needed.
302
4/4
✓ Branch 2 → 3 taken 17 times.
✓ Branch 2 → 4 taken 2 times.
✓ Branch 3 → 4 taken 3 times.
✓ Branch 3 → 5 taken 14 times.
19 if (src <= min_valid || src >= USERSPACE_PTR_MAX)
303 5 return 0;
304 // Debug-build footgun guard. In release the dereference below is a bare memcpy: a stale or unmapped src
305 // faults and takes down the host. This primitive is only for pointers the caller has proven are alive this
306 // frame, so in debug we confirm src is readable and assert otherwise, surfacing misuse during development.
307 // The probe compiles out in release (NDEBUG), keeping the hot path a single guarded memcpy.
308
1/2
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 14 times.
14 assert(is_readable(reinterpret_cast<const void *>(src), sizeof(uintptr_t)) &&
309 "read_ptr_unchecked: source pointer is not readable (stale/unmapped); "
310 "use seh_read_chain or read_ptr_unsafe for pointers that may not be alive");
311 14 uintptr_t addr{0};
312 14 std::memcpy(&addr, reinterpret_cast<const void *>(src), sizeof(addr));
313 // Apply the same user-mode window to the loaded value so a structurally valid source that yields a
314 // kernel-range or non-canonical pointer is rejected rather than propagated to the next link of the chain.
315
4/4
✓ Branch 9 → 10 taken 9 times.
✓ Branch 9 → 12 taken 5 times.
✓ Branch 10 → 11 taken 7 times.
✓ Branch 10 → 12 taken 2 times.
14 return (addr > min_valid && addr < USERSPACE_PTR_MAX) ? addr : 0;
316 }
317
318 /**
319 * @brief Checks if a specified memory region is writable.
320 * @details Verifies if the memory range has write permissions and is committed.
321 * @param address Starting address of the memory region.
322 * @param size Number of bytes in the memory region to check.
323 * @return true if the entire region is writable, false otherwise.
324 * @warning Carries the same hot-path cost and time-of-check to time-of-use caveat as @ref is_readable. When a
325 * hook receives a pointer the engine just wrote through, that pointer is already writable; gating the
326 * store behind this predicate adds a lock and a periodic VirtualQuery for no safety gain. Write
327 * directly (under SEH if the address may be stale) and reserve is_writable for one-shot setup
328 * validation.
329 */
330 [[nodiscard]] bool is_writable(void *address, size_t size);
331
332 /**
333 * @brief Writes a sequence of bytes to a target memory address, changing page protection around the write.
334 * @details Handles changing memory protection, performs the write operation, and restores original protection.
335 * Also flushes instruction cache. Automatically invalidates the affected cache range. If num_bytes
336 * exceeds MAX_WRITE_SIZE the function performs no write and returns MemoryError::SizeTooLarge.
337 * @param target_address Destination memory address.
338 * @param source_bytes Pointer to the source buffer containing data to write.
339 * @param num_bytes Number of bytes to write. Must not exceed MAX_WRITE_SIZE.
340 * @return std::expected<void, MemoryError> on success, or the specific error on failure.
341 * @note Setup / control-plane only. The two VirtualProtect calls, FlushInstructionCache, and the all-shard
342 * cache-range invalidation make this the right tool for one-shot CODE patches on read-only / executable
343 * pages, not a per-frame primitive. To write DATA to memory the target already keeps writable (a camera
344 * transform, a player field) every frame, use @ref seh_write_bytes or @ref seh_write_chain_bytes, which
345 * change no protection and run no flush or invalidation.
346 */
347 [[nodiscard]] std::expected<void, MemoryError> write_bytes(std::byte *target_address,
348 const std::byte *source_bytes, size_t num_bytes);
349
350 /**
351 * @struct ModuleRange
352 * @brief Mapped address range of a loaded PE image.
353 * @details A range is considered valid when @ref base is non-zero and @ref end strictly exceeds @ref base.
354 * Default-constructed instances (both fields zero) report @ref valid() as false so callers can return
355 * an "absent" range without optional.
356 */
357 struct ModuleRange
358 {
359 /// Mapped base address (equal to the HMODULE value).
360 uintptr_t base = 0;
361 /// Exclusive upper bound (base + SizeOfImage from the PE OptionalHeader).
362 uintptr_t end = 0;
363
364 /// True iff this range is populated (base != 0 && end > base).
365
4/4
✓ Branch 2 → 3 taken 1950572 times.
✓ Branch 2 → 5 taken 12 times.
✓ Branch 3 → 4 taken 1950570 times.
✓ Branch 3 → 5 taken 2 times.
1950584 [[nodiscard]] constexpr bool valid() const noexcept { return base != 0 && end > base; }
366 };
367
368 /**
369 * @brief Resolves the mapped range of the module containing @p address.
370 * @details Looks up the owning module via
371 * GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
372 * GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, ...), then reads SizeOfImage from the PE
373 * OptionalHeader behind a seh_read_bytes() guard. The result is cached per HMODULE for the process
374 * lifetime so repeated probes degenerate to a single GetModuleHandleEx call plus a hash lookup.
375 * @note The cache assumes mapped modules do not unload at addresses later reused by a different image. Game
376 * mods do not unload host modules; consumers that load and free DLLs at runtime should treat cached
377 * entries for an unloaded module's HMODULE as stale.
378 * @note Every call issues a GetModuleHandleEx lookup even on a cache hit. For a hot path that repeatedly checks
379 * whether a pointer lives inside one known module, capture @ref own_module_range or @ref
380 * host_module_range once and test with @ref contains, which is a branch-only comparison with no syscall.
381 * @param address Any address inside the target module. nullptr returns std::nullopt without a syscall.
382 * @return The module's range, or std::nullopt if @p address does not fall inside any loaded module or the PE
383 * headers are unreadable.
384 */
385 [[nodiscard]] std::optional<ModuleRange> module_range_for(const void *address) noexcept;
386
387 /**
388 * @brief Mapped range of the calling DLL (or EXE if DetourModKit is statically linked into the host process).
389 * @details DetourModKit is a static library, so the link target of @ref own_module_range itself is whichever
390 * DLL/EXE consumed the static library. Cached on first call via a function-local static, so subsequent
391 * calls are a single atomic load on the fast path.
392 * @return The owning module's range, or an invalid ModuleRange if the lookup or PE-header read failed (only
393 * possible under loader lock teardown or on corrupted images).
394 */
395 [[nodiscard]] ModuleRange own_module_range() noexcept;
396
397 /**
398 * @brief Mapped range of the host process EXE.
399 * @details Equivalent to module_range_for(GetModuleHandleW(nullptr)) with the same per-process caching as @ref
400 * own_module_range. Useful for sanity-checking that a freshly resolved vtable pointer lives inside the
401 * game image rather than on the heap or in an injected helper DLL.
402 * @return The EXE's range, or an invalid ModuleRange on lookup failure.
403 */
404 [[nodiscard]] ModuleRange host_module_range() noexcept;
405
406 /**
407 * @brief Tests whether @p ptr lies inside @p range.
408 * @return true iff @p range is valid and @p ptr is in [base, end).
409 */
410 1941730 [[nodiscard]] constexpr bool contains(ModuleRange range, uintptr_t ptr) noexcept
411 {
412
6/6
✓ Branch 3 → 4 taken 1941729 times.
✓ Branch 3 → 7 taken 1 time.
✓ Branch 4 → 5 taken 850305 times.
✓ Branch 4 → 7 taken 1091424 times.
✓ Branch 5 → 6 taken 461856 times.
✓ Branch 5 → 7 taken 388449 times.
1941730 return range.valid() && ptr >= range.base && ptr < range.end;
413 }
414
415 /**
416 * @brief SEH-guarded raw memory copy from @p addr into @p out.
417 * @details On MSVC, the copy runs inside a __try / __except frame whose filter accepts the foreign-read fault
418 * set (EXCEPTION_ACCESS_VIOLATION, STATUS_GUARD_PAGE_VIOLATION, and EXCEPTION_IN_PAGE_ERROR for a
419 * file-backed page that fails to page in), so any such fault in the middle of the copy unwinds cleanly
420 * and the function returns false. On MinGW the copy runs under a process-wide vectored exception
421 * handler that claims the same fault set (no per-call VirtualQuery on the success path); a fault
422 * anywhere in the span is swallowed and the function returns false. If the handler cannot be installed
423 * it falls back to VirtualQuery plus ReadProcessMemory for every region the read spans.
424 *
425 * The function is the underlying primitive for the typed @ref seh_read template and is exposed
426 * directly for callers that need to read a contiguous buffer of bytes (for example NUL-terminated
427 * strings of unknown length).
428 * @param addr Source address. Values below 0x10000 (the Windows
429 * reserved low-address range) are rejected without a read so stale or sentinel pointers cannot
430 * cause a first-chance exception.
431 * @param out Destination buffer. nullptr returns false.
432 * @param bytes Number of bytes to copy. Zero returns true (no-op).
433 * @return true on full success; false on any fault or invalid argument. On failure the contents of @p out are
434 * unspecified.
435 */
436 [[nodiscard]] bool seh_read_bytes(uintptr_t addr, void *out, size_t bytes) noexcept;
437
438 /**
439 * @brief SEH-guarded typed read of a trivially copyable T at @p addr.
440 * @details Forwards to @ref seh_read_bytes so the underlying __try frame lives in the translation unit that
441 * defines it. The bytes are read into untyped storage and reinterpreted with std::bit_cast, so no T
442 * object is constructed and the failure path runs no T constructor or destructor. On success the read
443 * collapses to a single memcpy of sizeof(T) bytes followed by a no-op bit_cast.
444 * @tparam T A trivially copyable type. Trivial copyability is what std::bit_cast requires and what makes a raw
445 * byte copy a valid reconstruction of the value. T need not be default constructible, so
446 * non-default-constructible POD-like types (e.g. structs with a deleted default constructor) can be
447 * read.
448 * @param addr Source address. Values below 0x10000 are rejected without a read; see @ref seh_read_bytes.
449 * @return The value on success, std::nullopt on any read fault.
450 */
451 template <typename T>
452 requires std::is_trivially_copyable_v<T>
453 462177 [[nodiscard]] std::optional<T> seh_read(uintptr_t addr) noexcept
454 {
455 462177 std::array<std::byte, sizeof(T)> storage{};
456
12/18
std::optional<_IMAGE_DOS_HEADER> DetourModKit::Memory::seh_read<_IMAGE_DOS_HEADER>(unsigned long long):
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 23 times.
std::optional<_IMAGE_NT_HEADERS64> DetourModKit::Memory::seh_read<_IMAGE_NT_HEADERS64>(unsigned long long):
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 1 time.
std::optional<_IMAGE_SECTION_HEADER> DetourModKit::Memory::seh_read<_IMAGE_SECTION_HEADER>(unsigned long long):
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 18 times.
std::optional<DetourModKit::Rtti::detail::ColHead> DetourModKit::Memory::seh_read<DetourModKit::Rtti::detail::ColHead>(unsigned long long):
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 230771 times.
std::optional<MemoryTest_SehRead_Struct_Test::TestBody()::Sample> DetourModKit::Memory::seh_read<MemoryTest_SehRead_Struct_Test::TestBody()::Sample>(unsigned long long):
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 1 time.
std::optional<unsigned char> DetourModKit::Memory::seh_read<unsigned char>(unsigned long long):
✓ Branch 5 → 6 taken 1 time.
✓ Branch 5 → 9 taken 11 times.
std::optional<int> DetourModKit::Memory::seh_read<int>(unsigned long long):
✓ Branch 5 → 6 taken 1 time.
✓ Branch 5 → 9 taken 26 times.
std::optional<unsigned int> DetourModKit::Memory::seh_read<unsigned int>(unsigned long long):
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 9 taken 1 time.
std::optional<unsigned long long> DetourModKit::Memory::seh_read<unsigned long long>(unsigned long long):
✓ Branch 5 → 6 taken 16 times.
✓ Branch 5 → 7 taken 231307 times.
462177 if (!seh_read_bytes(addr, storage.data(), sizeof(T)))
457 18 return std::nullopt;
458 462159 return std::bit_cast<T>(storage);
459 }
460
461 /**
462 * @brief Resolves a multi-level pointer chain under a single fault guard.
463 * @details Walks Cheat-Engine-style pointer-chain semantics: starting at @p base, every offset except the last
464 * is added and dereferenced to obtain the next link, and the final offset is added but not
465 * dereferenced, yielding the address of the target field. With offsets {o0, o1, o2} the result is
466 * (*(*(base + o0) + o1)) + o2.
467 *
468 * The entire walk runs inside one fault guard. On x64 MSVC the
469 * __try is table-driven (described by .pdata/.xdata emitted at compile time) and adds no runtime setup
470 * on the no-fault path whether the chain uses one guard or N, so the win over calling @ref seh_read
471 * once per link is not SEH-frame setup:
472 * it is one out-of-line call instead of N, each intermediate link kept in a register instead of
473 * round-tripped through std::optional, and a single argument validation. On MinGW (no SEH) the saving
474 * is concrete: one guarded helper call instead of N, each intermediate link read through @ref
475 * read_ptr_unsafe (guarded by the process-wide vectored handler) and the final address computed
476 * without a read.
477 *
478 * Each intermediate link is screened with @ref plausible_userspace_ptr; a link that faults or yields
479 * an implausible pointer aborts the walk and returns std::nullopt. The returned address is not
480 * dereferenced and not range-checked by this function; the caller reads it (typically via @ref
481 * seh_read or @ref seh_read_chain).
482 * @param base Root address of the chain.
483 * @param offsets Byte offsets applied left to right. An empty span returns @p base unchanged.
484 * @return The resolved target address, or std::nullopt if any intermediate dereference faults or produces an
485 * implausible pointer.
486 */
487 [[nodiscard]] std::optional<uintptr_t> seh_resolve_chain(uintptr_t base,
488 std::span<const ptrdiff_t> offsets) noexcept;
489
490 /**
491 * @brief Convenience overload accepting a braced offset list.
492 * @see seh_resolve_chain(uintptr_t, std::span<const ptrdiff_t>)
493 */
494 [[nodiscard]] inline std::optional<uintptr_t>
495 6 seh_resolve_chain(uintptr_t base, std::initializer_list<ptrdiff_t> offsets) noexcept
496 {
497 6 return seh_resolve_chain(base, std::span<const ptrdiff_t>(offsets.begin(), offsets.size()));
498 }
499
500 /**
501 * @brief Resolves a pointer chain and reads a raw byte range at its end.
502 * @details Performs the same walk as @ref seh_resolve_chain and then copies @p bytes from the resolved address
503 * into @p out under one fault guard, so a fault anywhere in the resolve or the terminal read takes the
504 * same failure path and cannot leave a partially walked chain observable to the caller. On MinGW the
505 * chain is resolved via @ref read_ptr_unsafe and the terminal read uses @ref seh_read_bytes.
506 * @param base Root address of the chain.
507 * @param offsets Byte offsets applied left to right (see @ref seh_resolve_chain). An empty span reads at @p
508 * base.
509 * @param out Destination buffer. nullptr returns false.
510 * @param bytes Number of bytes to copy. Zero returns true (no-op).
511 * @return true on a fully successful resolve and read; false if any intermediate link faults or is implausible,
512 * or the terminal read faults. On failure the contents of @p out are unspecified.
513 */
514 [[nodiscard]] bool seh_read_chain_bytes(uintptr_t base, std::span<const ptrdiff_t> offsets, void *out,
515 size_t bytes) noexcept;
516
517 /**
518 * @brief Resolves a pointer chain and reads a typed value at its end.
519 * @details Forwards to @ref seh_read_chain_bytes, so the chain walk and the typed read share a single fault
520 * guard. The bytes are read into untyped storage and reinterpreted with std::bit_cast, so no T object
521 * is constructed on the failure path.
522 * @tparam T A trivially copyable type (see @ref seh_read; T need not be default constructible).
523 * @param base Root address of the chain.
524 * @param offsets Byte offsets applied left to right (see @ref seh_resolve_chain).
525 * @return The value on success, std::nullopt if any link faults or is implausible or the terminal read faults.
526 */
527 template <typename T>
528 requires std::is_trivially_copyable_v<T>
529 4 [[nodiscard]] std::optional<T> seh_read_chain(uintptr_t base, std::span<const ptrdiff_t> offsets) noexcept
530 {
531 4 std::array<std::byte, sizeof(T)> storage{};
532
3/6
std::optional<MemorySehReadChain_ReadsNonDefaultConstructibleType_Test::TestBody()::NoDefault> DetourModKit::Memory::seh_read_chain<MemorySehReadChain_ReadsNonDefaultConstructibleType_Test::TestBody()::NoDefault>(unsigned long long, std::span<long long const, 18446744073709551615ull>):
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 1 time.
std::optional<unsigned int> DetourModKit::Memory::seh_read_chain<unsigned int>(unsigned long long, std::span<long long const, 18446744073709551615ull>):
✓ Branch 5 → 6 taken 1 time.
✗ Branch 5 → 9 not taken.
std::optional<unsigned long long> DetourModKit::Memory::seh_read_chain<unsigned long long>(unsigned long long, std::span<long long const, 18446744073709551615ull>):
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 2 times.
4 if (!seh_read_chain_bytes(base, offsets, storage.data(), sizeof(T)))
533 1 return std::nullopt;
534 3 return std::bit_cast<T>(storage);
535 }
536
537 /**
538 * @brief Convenience overload accepting a braced offset list.
539 * @see seh_read_chain(uintptr_t, std::span<const ptrdiff_t>)
540 */
541 template <typename T>
542 requires std::is_trivially_copyable_v<T>
543 4 [[nodiscard]] std::optional<T> seh_read_chain(uintptr_t base, std::initializer_list<ptrdiff_t> offsets) noexcept
544 {
545 4 return seh_read_chain<T>(base, std::span<const ptrdiff_t>(offsets.begin(), offsets.size()));
546 }
547
548 /**
549 * @brief SEH-guarded raw memory copy from @p source into @p addr.
550 * @details The write sibling of @ref seh_read_bytes for memory the target already keeps writable (live game
551 * data such as a camera transform or player field). On MSVC the copy runs inside a __try / __except
552 * frame whose filter accepts the foreign-access fault set (EXCEPTION_ACCESS_VIOLATION,
553 * STATUS_GUARD_PAGE_VIOLATION, EXCEPTION_IN_PAGE_ERROR), so a fault mid-copy unwinds cleanly and the
554 * function returns false. On MinGW x64 the copy normally runs under the same process-wide vectored
555 * guard the read primitives use, armed over the destination span; if the handler cannot be installed,
556 * and on 32-bit MinGW, it falls back to VirtualQuery plus WriteProcessMemory and fails closed unless
557 * the destination is currently writable.
558 * @param addr Destination address. Values below 0x10000 are rejected without a write.
559 * @param source Source buffer. nullptr returns false.
560 * @param bytes Number of bytes to copy. Zero returns true (no-op).
561 * @return true on full success; false on any fault or invalid argument. On a fault mid-copy the target may have
562 * been partially written.
563 * @warning This does NOT change page protection, flush the instruction cache, or invalidate the readability
564 * cache. It is for writing DATA to memory the target already keeps writable, which is the per-frame
565 * hot-path case. To patch CODE on read-only / executable pages, use @ref write_bytes instead.
566 * @note Callback-safe on the established hot path: MSVC and the installed MinGW x64 VEH path allocate nothing,
567 * take no lock, and issue no syscall for the copy itself. On MinGW, call @ref init_cache during setup if
568 * you want the VEH installed before a hook callback can be the first guarded access.
569 */
570 [[nodiscard]] bool seh_write_bytes(uintptr_t addr, const void *source, size_t bytes) noexcept;
571
572 /**
573 * @brief SEH-guarded typed write of a trivially copyable T to @p addr.
574 * @details Forwards to @ref seh_write_bytes, so the underlying guard lives in the translation unit that defines
575 * it. The value's object representation is copied byte-for-byte; no T object is constructed at @p
576 * addr.
577 * @tparam T A trivially copyable type.
578 * @param addr Destination address (see @ref seh_write_bytes).
579 * @param value Value whose object representation is written.
580 * @return true on success, false on any write fault or invalid address.
581 */
582 template <typename T>
583 requires std::is_trivially_copyable_v<T>
584 1 [[nodiscard]] bool seh_write(uintptr_t addr, const T &value) noexcept
585 {
586 1 return seh_write_bytes(addr, std::addressof(value), sizeof(T));
587 }
588
589 /**
590 * @brief Resolves a pointer chain and writes a raw byte range at its end.
591 * @details Performs the same walk as @ref seh_resolve_chain and then copies @p bytes from @p source into the
592 * resolved address through the guarded write path, so a fault in the resolve or the terminal write
593 * fails closed. This is the per-frame WRITE counterpart of @ref seh_read_chain_bytes: it changes no
594 * page protection and runs no i-cache flush or cache invalidation, so writing a camera transform every
595 * frame through it is a guarded copy, not the heavy @ref write_bytes path.
596 * @param base Root address of the chain.
597 * @param offsets Byte offsets applied left to right (see @ref seh_resolve_chain). An empty span writes at @p
598 * base.
599 * @param source Source buffer. nullptr returns false.
600 * @param bytes Number of bytes to copy. Zero returns true (no-op).
601 * @return true on a fully successful resolve and write; false if any intermediate link faults or is
602 * implausible,
603 * or the terminal write faults. On a terminal fault the target may have been partially written.
604 */
605 [[nodiscard]] bool seh_write_chain_bytes(uintptr_t base, std::span<const ptrdiff_t> offsets, const void *source,
606 size_t bytes) noexcept;
607
608 /**
609 * @brief Convenience overload accepting a braced offset list.
610 * @see seh_write_chain_bytes(uintptr_t, std::span<const ptrdiff_t>, const void *, size_t)
611 */
612 1 [[nodiscard]] inline bool seh_write_chain_bytes(uintptr_t base, std::initializer_list<ptrdiff_t> offsets,
613 const void *source, size_t bytes) noexcept
614 {
615 2 return seh_write_chain_bytes(base, std::span<const ptrdiff_t>(offsets.begin(), offsets.size()), source,
616 1 bytes);
617 }
618
619 /**
620 * @brief Resolves a pointer chain and writes a typed value at its end.
621 * @details Forwards to @ref seh_write_chain_bytes. The value's object representation is copied byte-for-byte.
622 * @tparam T A trivially copyable type.
623 * @param base Root address of the chain.
624 * @param offsets Byte offsets applied left to right (see @ref seh_resolve_chain).
625 * @param value Value whose object representation is written.
626 * @return true on success, false if any link faults or is implausible or the terminal write faults.
627 */
628 template <typename T>
629 requires std::is_trivially_copyable_v<T>
630 4 [[nodiscard]] bool seh_write_chain(uintptr_t base, std::span<const ptrdiff_t> offsets, const T &value) noexcept
631 {
632 4 return seh_write_chain_bytes(base, offsets, std::addressof(value), sizeof(T));
633 }
634
635 /**
636 * @brief Convenience overload accepting a braced offset list.
637 * @see seh_write_chain(uintptr_t, std::span<const ptrdiff_t>, const T &)
638 */
639 template <typename T>
640 requires std::is_trivially_copyable_v<T>
641 3 [[nodiscard]] bool seh_write_chain(uintptr_t base, std::initializer_list<ptrdiff_t> offsets,
642 const T &value) noexcept
643 {
644 3 return seh_write_chain<T>(base, std::span<const ptrdiff_t>(offsets.begin(), offsets.size()), value);
645 }
646 } // namespace Memory
647 } // namespace DetourModKit
648
649 #endif // DETOURMODKIT_MEMORY_HPP
650