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/18std::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/6std::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 |