include/DetourModKit/memory.hpp
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | #ifndef DETOURMODKIT_MEMORY_HPP | ||
| 2 | #define DETOURMODKIT_MEMORY_HPP | ||
| 3 | |||
| 4 | /** | ||
| 5 | * @file memory.hpp | ||
| 6 | * @brief The guarded-memory surface: fault-tolerant reads, writes, pointer-chain walks, and a protection guard. | ||
| 7 | * @details A guarded access turns a fault into a `Result` error instead of a host termination. The fault guard (MSVC | ||
| 8 | * `__try`, MinGW vectored handler) lives entirely in the engine translation unit, so this header pulls in no | ||
| 9 | * `<windows.h>` and no SEH. | ||
| 10 | * | ||
| 11 | * The surface is layered by safety: | ||
| 12 | * - `read`, `read_into`, `write`, `write_bytes`, and `walk` are GUARDED. They validate, fault-protect, and | ||
| 13 | * report failure as an `Error`. Use them whenever the address can be stale. | ||
| 14 | * - `is_plausible_ptr` is a pure arithmetic pre-screen with no syscall and no access. | ||
| 15 | * - The cache and the `is_readable` and `is_writable` predicates answer protection questions for one-shot | ||
| 16 | * setup validation and diagnostics, not for per-frame hot paths. Each consults a lock and, on a miss, can | ||
| 17 | * walk the range one `VirtualQuery` per region. | ||
| 18 | * - `unchecked::read` performs NO validation and FAULTS THE HOST on an unreadable byte. | ||
| 19 | * @warning `[B-100]` Under the loader lock, call only the Callback-safe entry points in this header. Cache startup and | ||
| 20 | * the exact-case module lookup fail closed. | ||
| 21 | */ | ||
| 22 | |||
| 23 | #include "DetourModKit/address.hpp" | ||
| 24 | #include "DetourModKit/defines.hpp" | ||
| 25 | #include "DetourModKit/error.hpp" | ||
| 26 | #include "DetourModKit/region.hpp" | ||
| 27 | |||
| 28 | #include <array> | ||
| 29 | #include <bit> | ||
| 30 | #include <cassert> | ||
| 31 | #include <climits> | ||
| 32 | #include <cstddef> | ||
| 33 | #include <cstdint> | ||
| 34 | #include <cstring> | ||
| 35 | #include <limits> | ||
| 36 | #include <memory> | ||
| 37 | #include <span> | ||
| 38 | #include <string> | ||
| 39 | #include <string_view> | ||
| 40 | #include <type_traits> | ||
| 41 | |||
| 42 | namespace DetourModKit | ||
| 43 | { | ||
| 44 | namespace detail | ||
| 45 | { | ||
| 46 | /** | ||
| 47 | * @brief Trait that is true for any non-owning view type: a `std::span<U, Extent>` of any element type, or a | ||
| 48 | * `std::basic_string_view`. | ||
| 49 | * @details `[B-21]` A view is trivially copyable, but its bit-copy stores the view's pointer and length. Typed | ||
| 50 | * `write<T>` rejects every view, even byte spans. Only `write_in_place` routes a byte span to its | ||
| 51 | * byte-span overload. Use `write_bytes` for other views. Constraint sites inspect | ||
| 52 | * `std::remove_cvref_t<T>` so a cv/ref qualification cannot slip one past. | ||
| 53 | */ | ||
| 54 | template <class T> inline constexpr bool is_non_owning_view_v = false; | ||
| 55 | template <class U, std::size_t Extent> inline constexpr bool is_non_owning_view_v<std::span<U, Extent>> = true; | ||
| 56 | template <class CharT, class Traits> | ||
| 57 | inline constexpr bool is_non_owning_view_v<std::basic_string_view<CharT, Traits>> = true; | ||
| 58 | |||
| 59 | /** | ||
| 60 | * @brief Opt-in trait for aggregate types whose every object representation may be read from foreign bytes. | ||
| 61 | * @details The default is false because C++23 cannot inspect aggregate members: a trivially copyable class may | ||
| 62 | * still contain `bool` or another representation-sensitive member. Specialize this trait to | ||
| 63 | * `std::true_type` only after verifying the complete transitive object representation, including that | ||
| 64 | * the type has no padding bytes whose value the caller intends to interpret. Built-in arrays are | ||
| 65 | * checked recursively; `std::array` opts in when its element type is representation-safe. | ||
| 66 | * @tparam T Aggregate type to classify. | ||
| 67 | */ | ||
| 68 | template <class T> struct enable_representation_safe_aggregate : std::false_type | ||
| 69 | { | ||
| 70 | }; | ||
| 71 | |||
| 72 | /// True when @p T explicitly opts into representation-safe aggregate reads. | ||
| 73 | template <class T> | ||
| 74 | inline constexpr bool enable_representation_safe_aggregate_v = | ||
| 75 | enable_representation_safe_aggregate<std::remove_cv_t<T>>::value; | ||
| 76 | |||
| 77 | /** | ||
| 78 | * @brief True when @p E is an enumeration with a fixed underlying type. | ||
| 79 | * @details [dcl.enum]/8 gives such an enumeration the value range of its underlying type, so every bit pattern | ||
| 80 | * of that type is a valid enumerator value. Direct-list-initialization from the underlying type is | ||
| 81 | * well-formed only for the fixed case, which is the detection this concept uses. | ||
| 82 | */ | ||
| 83 | template <class E> | ||
| 84 | concept fixed_underlying_enum = std::is_enum_v<E> && requires { E{std::underlying_type_t<E>{}}; }; | ||
| 85 | |||
| 86 | /** | ||
| 87 | * @brief True when @p F is a binary floating-point type whose object representation carries no padding bits. | ||
| 88 | * @details Padding bits have no defined value, so a foreign byte pattern read into such a type is not | ||
| 89 | * necessarily a valid object representation. The bit-count test is required because `is_iec559` alone | ||
| 90 | * is not enough: MinGW's 16-byte x87 `long double` reports `is_iec559` for an 80-bit format. | ||
| 91 | */ | ||
| 92 | template <class F> [[nodiscard]] constexpr bool padding_free_binary_float() noexcept | ||
| 93 | { | ||
| 94 | using Limits = std::numeric_limits<F>; | ||
| 95 | if constexpr (!Limits::is_iec559 || Limits::radix != 2 || Limits::max_exponent <= 0 || Limits::digits <= 0) | ||
| 96 | { | ||
| 97 | return false; | ||
| 98 | } | ||
| 99 | else | ||
| 100 | { | ||
| 101 | const int exponent_bits = | ||
| 102 | static_cast<int>(std::bit_width(static_cast<unsigned long long>(Limits::max_exponent))); | ||
| 103 | return 1 + exponent_bits + (Limits::digits - 1) == static_cast<int>(sizeof(F) * CHAR_BIT); | ||
| 104 | } | ||
| 105 | } | ||
| 106 | |||
| 107 | /// @cond | ||
| 108 | template <class T> struct representation_read_value | ||
| 109 | { | ||
| 110 | using type = T; | ||
| 111 | }; | ||
| 112 | |||
| 113 | template <class T, std::size_t Size> struct representation_read_value<T[Size]> | ||
| 114 | { | ||
| 115 | using type = std::array<typename representation_read_value<T>::type, Size>; | ||
| 116 | }; | ||
| 117 | |||
| 118 | template <class T> using representation_read_value_t = typename representation_read_value<T>::type; | ||
| 119 | |||
| 120 | template <class T> | ||
| 121 | [[nodiscard]] representation_read_value_t<T> | ||
| 122 | 2509674 | decode_foreign_representation(const std::array<std::byte, sizeof(T)> &storage) noexcept | |
| 123 | { | ||
| 124 | static_assert( | ||
| 125 | sizeof(representation_read_value_t<T>) == sizeof(T), | ||
| 126 | "a built-in array read requires the equivalent std::array to have identical size" | ||
| 127 | ); | ||
| 128 | 2509674 | return std::bit_cast<representation_read_value_t<T>>(storage); | |
| 129 | } | ||
| 130 | |||
| 131 | template <class T> [[nodiscard]] constexpr bool representation_safe() noexcept | ||
| 132 | { | ||
| 133 | using U = std::remove_cv_t<T>; | ||
| 134 | if constexpr (std::is_same_v<U, bool>) | ||
| 135 | return false; | ||
| 136 | else if constexpr (std::is_bounded_array_v<U>) | ||
| 137 | return representation_safe<std::remove_extent_t<U>>(); | ||
| 138 | else if constexpr (std::is_unbounded_array_v<U>) | ||
| 139 | return false; | ||
| 140 | else if constexpr (std::is_integral_v<U>) | ||
| 141 | return true; | ||
| 142 | else if constexpr (std::is_floating_point_v<U>) | ||
| 143 | return padding_free_binary_float<U>(); | ||
| 144 | else if constexpr (std::is_enum_v<U>) | ||
| 145 | // The underlying type must itself qualify: `enum class E : bool` has a fixed base, yet [dcl.enum]/8 | ||
| 146 | // gives it only bool's two values, so a foreign 0x02 is no more valid as an E than as a bool. | ||
| 147 | return fixed_underlying_enum<U> && representation_safe<std::underlying_type_t<U>>(); | ||
| 148 | else if constexpr (std::is_pointer_v<U>) | ||
| 149 | return true; | ||
| 150 | else if constexpr (std::is_member_pointer_v<U> || std::is_null_pointer_v<U>) | ||
| 151 | return false; | ||
| 152 | else | ||
| 153 | return (std::is_class_v<U> || std::is_union_v<U>) && std::is_trivially_copyable_v<U> && | ||
| 154 | enable_representation_safe_aggregate_v<U>; | ||
| 155 | } | ||
| 156 | |||
| 157 | template <class T, std::size_t Size> | ||
| 158 | struct enable_representation_safe_aggregate<std::array<T, Size>> : std::bool_constant<representation_safe<T>()> | ||
| 159 | { | ||
| 160 | }; | ||
| 161 | |||
| 162 | template <> struct enable_representation_safe_aggregate<Address> : std::true_type | ||
| 163 | { | ||
| 164 | }; | ||
| 165 | /// @endcond | ||
| 166 | |||
| 167 | static_assert( | ||
| 168 | std::is_trivially_copyable_v<Address> && std::is_standard_layout_v<Address> && | ||
| 169 | sizeof(Address) == sizeof(std::uintptr_t) && alignof(Address) == alignof(std::uintptr_t), | ||
| 170 | "Address participates in representation-safe reads only while it is exactly one padding-free " | ||
| 171 | "std::uintptr_t; a stored flag or a wider member would make read<Address> unsound" | ||
| 172 | ); | ||
| 173 | |||
| 174 | /** | ||
| 175 | * @brief True when every bit pattern of @p T's object representation is a valid value, so forming @p T from | ||
| 176 | * arbitrary foreign bytes with `std::bit_cast` is well defined. | ||
| 177 | * @details The participation gate for the raw typed reads (@ref memory::read, @ref memory::unchecked::read, and | ||
| 178 | * the engine's `detail::guarded_read`). The domain is an explicit allowlist, not "every scalar": | ||
| 179 | * - every integral type except `bool`; | ||
| 180 | * - a binary floating-point type with no padding bits (@ref padding_free_binary_float), which admits | ||
| 181 | * `float` and `double` on both toolchains and `long double` only on MSVC, where it is `double`; | ||
| 182 | * - an enumeration with a fixed underlying type (@ref fixed_underlying_enum) that is itself in the | ||
| 183 | * domain, which admits every scoped enumeration over an integer and `std::byte`; | ||
| 184 | * - an object or function pointer, as a Windows x64 ABI concession, not a portable C++ theorem. The | ||
| 185 | * result does NOT recover pointer provenance, so treat it as an address to screen with | ||
| 186 | * @ref memory::is_plausible_ptr and read through a guarded route, never as a pointer to | ||
| 187 | * dereference. | ||
| 188 | * - a bounded built-in array or `std::array` whose element type qualifies, recursively; | ||
| 189 | * - @ref Address, and any other class or union explicitly opted in through | ||
| 190 | * @ref enable_representation_safe_aggregate. | ||
| 191 | * | ||
| 192 | * Rejected: `bool`, because a foreign byte such as `0x02` is not a valid `bool` object | ||
| 193 | * representation and the bit-cast is undefined behavior before a `Result` can report it. Decode it | ||
| 194 | * with @ref memory::read_bool. Also rejected: `std::nullptr_t`; member-object and member-function | ||
| 195 | * pointers, whose representations are implementation-defined multi-field structures; an unscoped | ||
| 196 | * enumeration with no fixed base; an enumeration over `bool`; an unbounded array; and a | ||
| 197 | * floating-point format with padding bits, such as MinGW's 16-byte x87 `long double`. Use | ||
| 198 | * @ref memory::read_into to copy any of these as raw bytes and decode them yourself. | ||
| 199 | * | ||
| 200 | * Enum DOMAIN validity is a separate concern: a fixed-underlying enumeration's bit patterns are all | ||
| 201 | * valid representations even when a specific value is semantically invalid for an API. | ||
| 202 | */ | ||
| 203 | template <class T> inline constexpr bool is_representation_safe_v = representation_safe<T>(); | ||
| 204 | } // namespace detail | ||
| 205 | |||
| 206 | namespace memory | ||
| 207 | { | ||
| 208 | /** | ||
| 209 | * @brief Inclusive lower bound of the canonical x64 user-mode address window. | ||
| 210 | * @details The low 64 KiB is the reserved null-dereference region, so any value below this bound cannot be a | ||
| 211 | * valid object pointer. | ||
| 212 | */ | ||
| 213 | inline constexpr std::uintptr_t USERSPACE_PTR_MIN = 0x10000; | ||
| 214 | |||
| 215 | /** | ||
| 216 | * @brief Exclusive upper bound of the canonical x64 user-mode address window. | ||
| 217 | * @details Mapped user addresses sit below the 47-bit canonical split, so a value at or above this bound is a | ||
| 218 | * kernel-range or non-canonical address. | ||
| 219 | */ | ||
| 220 | inline constexpr std::uintptr_t USERSPACE_PTR_MAX = 0x0000800000000000ULL; | ||
| 221 | |||
| 222 | /// Maximum byte count a single @ref write_bytes call accepts before failing with ErrorCode::SizeTooLarge. | ||
| 223 | inline constexpr std::size_t MAX_WRITE_SIZE = 64ULL * 1024 * 1024; | ||
| 224 | |||
| 225 | /// Default number of region entries the protection cache holds. | ||
| 226 | inline constexpr std::size_t DEFAULT_CACHE_SIZE = 256; | ||
| 227 | /// Default cache entry lifetime, in milliseconds, before a re-query. | ||
| 228 | inline constexpr unsigned int DEFAULT_CACHE_EXPIRY_MS = 50; | ||
| 229 | /// Minimum permitted cache size. | ||
| 230 | inline constexpr std::size_t MIN_CACHE_SIZE = 1; | ||
| 231 | /// Default number of cache shards, striped to reduce reader contention. | ||
| 232 | inline constexpr std::size_t DEFAULT_CACHE_SHARD_COUNT = 16; | ||
| 233 | /// Default multiplier bounding the cache's hard maximum size relative to its configured size. | ||
| 234 | inline constexpr std::size_t DEFAULT_MAX_CACHE_SIZE_MULTIPLIER = 2; | ||
| 235 | |||
| 236 | /** | ||
| 237 | * @brief Structural plausibility test for an x64 user-mode pointer. | ||
| 238 | * @param address The address to test. | ||
| 239 | * @return True only when @p address lies in [@ref USERSPACE_PTR_MIN, @ref USERSPACE_PTR_MAX). | ||
| 240 | * @details Rejects obviously bad values (null, small enum-shaped integers, non-canonical addresses) before a | ||
| 241 | * guarded read pays for a fault. It does NOT prove the pointer is mapped or that the target object is | ||
| 242 | * the expected type. Pair it with @ref module_of and a guarded @ref read for full validation. | ||
| 243 | * @note Callback-safe: pure `constexpr` arithmetic with no memory access, lock, or syscall. | ||
| 244 | */ | ||
| 245 | 1726 | [[nodiscard]] inline constexpr bool is_plausible_ptr(Address address) noexcept | |
| 246 | { | ||
| 247 | 1726 | const std::uintptr_t value = address.raw(); | |
| 248 |
4/4✓ Branch 3 → 4 taken 1718 times.
✓ Branch 3 → 6 taken 8 times.
✓ Branch 4 → 5 taken 1712 times.
✓ Branch 4 → 6 taken 6 times.
|
1726 | return value >= USERSPACE_PTR_MIN && value < USERSPACE_PTR_MAX; |
| 249 | } | ||
| 250 | |||
| 251 | /** | ||
| 252 | * @brief Guarded copy of @p out.size() bytes from @p address into @p out. | ||
| 253 | * @param address Source address. | ||
| 254 | * @param out Destination byte span. An empty span is a successful no-op. | ||
| 255 | * @return An empty `Result` on full success; `ErrorCode::OverlappingRanges` when @p out intersects the source | ||
| 256 | * range (see @ref ErrorCode::OverlappingRanges; nothing is read); otherwise `ErrorCode::ReadFaulted` on | ||
| 257 | * any fault or rejected argument, with the faulting byte's address in `Error::detail` - a byte inside | ||
| 258 | * the requested source span `[address, address + out.size())`, not inside the destination @p out. It is | ||
| 259 | * the first unreadable byte for the small spans a typed @ref read issues; for a span wide enough that | ||
| 260 | * the platform's `memcpy` touches bytes out of order it can be a later byte of the same unreadable | ||
| 261 | * region. A span rejected before any access, and the MinGW fallback that validates through | ||
| 262 | * `VirtualQuery` instead of faulting, have no faulting byte to name and report @p address instead. | ||
| 263 | * @details The byte-level read primitive every typed @ref read forwards to. The copy runs under the engine's | ||
| 264 | * fault guard, so it reports a fault anywhere in the span without host termination. The pre-screen | ||
| 265 | * rejects only out-of-range spans: addresses below @ref USERSPACE_PTR_MIN, wrapped ends, or ends above | ||
| 266 | * @ref USERSPACE_PTR_MAX. It does not prove pointer validity. An in-range pointer can remain unmapped | ||
| 267 | * or stale, and the fault guard reports that access failure. On failure the contents of @p out are | ||
| 268 | * unspecified. | ||
| 269 | * @note Callback-safe: allocates nothing, takes no lock, and on the established hot path issues no syscall. | ||
| 270 | */ | ||
| 271 | [[nodiscard]] Result<void> read_into(Address address, std::span<std::byte> out) noexcept; | ||
| 272 | |||
| 273 | /** | ||
| 274 | * @brief Guarded typed read of a representation-safe @p T at @p address. | ||
| 275 | * @tparam T A trivially copyable type in the representation-safe domain | ||
| 276 | * (@ref detail::is_representation_safe_v, which enumerates what participates and what does not). It | ||
| 277 | * need not be default constructible: the bytes are read into untyped storage and reinterpreted with | ||
| 278 | * `std::bit_cast`, so no @p T object is constructed on the failure path. A type outside the domain is | ||
| 279 | * a compile error, not a runtime risk; decode `bool` through @ref read_bool and anything else through | ||
| 280 | * raw bytes with @ref read_into. | ||
| 281 | * @param address Source address. | ||
| 282 | * @return The value on success, or the propagated @ref read_into error on a read fault. A top-level bounded | ||
| 283 | * built-in array is returned as the equivalent nested `std::array`, because C++ functions cannot return | ||
| 284 | * a built-in array by value. | ||
| 285 | * @details Forwards to @ref read_into so the `__try` frame stays in the engine TU. On success, the read | ||
| 286 | * collapses to a single guarded copy of `sizeof(T)` bytes followed by a no-op bit_cast. | ||
| 287 | * @note Callback-safe (see @ref read_into). | ||
| 288 | */ | ||
| 289 | template <class T> | ||
| 290 | requires(std::is_trivially_copyable_v<T> && detail::is_representation_safe_v<T>) | ||
| 291 | 1520 | [[nodiscard]] Result<detail::representation_read_value_t<T>> read(Address address) noexcept | |
| 292 | { | ||
| 293 | 1520 | std::array<std::byte, sizeof(T)> storage{}; | |
| 294 |
14/24std::expected<DetourModKit::detail::representation_read_value<_IMAGE_DOS_HEADER>::type, DetourModKit::Error> DetourModKit::memory::read<_IMAGE_DOS_HEADER>(DetourModKit::Address):
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 10 taken 739 times.
std::expected<DetourModKit::detail::representation_read_value<_IMAGE_NT_HEADERS64>::type, DetourModKit::Error> DetourModKit::memory::read<_IMAGE_NT_HEADERS64>(DetourModKit::Address):
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 10 taken 736 times.
std::expected<DetourModKit::detail::representation_read_value<int [2][3]>::type, DetourModKit::Error> DetourModKit::memory::read<int [2][3]>(DetourModKit::Address):
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 10 taken 1 time.
std::expected<DetourModKit::detail::representation_read_value<int [2]>::type, DetourModKit::Error> DetourModKit::memory::read<int [2]>(DetourModKit::Address):
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 10 taken 1 time.
std::expected<DetourModKit::detail::representation_read_value<(anonymous namespace)::OptedAggregate>::type, DetourModKit::Error> DetourModKit::memory::read<(anonymous namespace)::OptedAggregate>(DetourModKit::Address):
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 10 taken 1 time.
std::expected<DetourModKit::detail::representation_read_value<representation_read::Sample>::type, DetourModKit::Error> DetourModKit::memory::read<representation_read::Sample>(DetourModKit::Address):
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 10 taken 1 time.
std::expected<DetourModKit::detail::representation_read_value<representation_read::NoDefault>::type, DetourModKit::Error> DetourModKit::memory::read<representation_read::NoDefault>(DetourModKit::Address):
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 10 taken 1 time.
std::expected<DetourModKit::detail::representation_read_value<DetourModKit::Address>::type, DetourModKit::Error> DetourModKit::memory::read<DetourModKit::Address>(DetourModKit::Address):
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 10 taken 1 time.
std::expected<DetourModKit::detail::representation_read_value<std::array<std::byte, 4ull> >::type, DetourModKit::Error> DetourModKit::memory::read<std::array<std::byte, 4ull> >(DetourModKit::Address):
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 10 taken 1 time.
std::expected<DetourModKit::detail::representation_read_value<unsigned char>::type, DetourModKit::Error> DetourModKit::memory::read<unsigned char>(DetourModKit::Address):
✓ Branch 5 → 6 taken 1 time.
✗ Branch 5 → 10 not taken.
std::expected<DetourModKit::detail::representation_read_value<unsigned int>::type, DetourModKit::Error> DetourModKit::memory::read<unsigned int>(DetourModKit::Address):
✓ Branch 5 → 6 taken 2 times.
✓ Branch 5 → 10 taken 2 times.
std::expected<DetourModKit::detail::representation_read_value<unsigned long long>::type, DetourModKit::Error> DetourModKit::memory::read<unsigned long long>(DetourModKit::Address):
✓ Branch 5 → 6 taken 17 times.
✓ Branch 5 → 10 taken 16 times.
|
1520 | if (auto outcome = read_into(address, storage); !outcome) |
| 295 | { | ||
| 296 | 20 | return std::unexpected(outcome.error()); | |
| 297 | } | ||
| 298 | 1500 | return detail::decode_foreign_representation<T>(storage); | |
| 299 | } | ||
| 300 | |||
| 301 | /** | ||
| 302 | * @brief Guarded checked decode of a single foreign byte into a `bool`. | ||
| 303 | * @param address Source address of the byte. | ||
| 304 | * @return `false`/`true` for a byte of `0`/`1`; `ErrorCode::ReadFaulted` (faulting address in `Error::detail`) | ||
| 305 | * on a read fault, or `ErrorCode::InvalidRepresentation` (source address in `Error::detail`) for any | ||
| 306 | * other byte value. | ||
| 307 | * @details The representation-safe route for `bool`, which the raw typed @ref read excludes: it reads one byte | ||
| 308 | * through the fault guard and validates it before forming the `bool`, so an arbitrary foreign byte can | ||
| 309 | * never be bit-cast into an invalid `bool`. Extend this checked-decoder pattern for any other | ||
| 310 | * representation-sensitive type a caller needs. | ||
| 311 | * @note Callback-safe (see @ref read_into). | ||
| 312 | */ | ||
| 313 | [[nodiscard]] Result<bool> read_bool(Address address) noexcept; | ||
| 314 | |||
| 315 | /** | ||
| 316 | * @brief Guarded write of a byte span to @p address, changing page protection only if it must. | ||
| 317 | * @param address Destination address. | ||
| 318 | * @param source Source byte span. An empty span is a successful no-op, but the null-target check runs | ||
| 319 | * first: a null @p address fails with `NullTargetAddress` even for an empty span. | ||
| 320 | * @return An empty `Result` on success; one of `ErrorCode::NullTargetAddress`, `NullSourceBytes`, | ||
| 321 | * `SizeTooLarge` (over @ref MAX_WRITE_SIZE), `OverlappingRanges` (@p source intersects the target | ||
| 322 | * range; nothing is written), `ProtectionChangeFailed`, `WriteFaulted` (nothing was written), | ||
| 323 | * `WriteMayBePartial` (the changed prefix is indeterminate, as @ref ErrorCode::WriteMayBePartial | ||
| 324 | * defines), | ||
| 325 | * `InstructionFlushFailed`, or `ProtectionRestoreFailed`. | ||
| 326 | * @details The escalating DATA write. @ref patch_code is the route for bytes that are executed. It first | ||
| 327 | * attempts a guarded write that changes NO page protection, so a target that is already writable | ||
| 328 | * costs no `VirtualProtect` and no instruction-cache flush. Only a fault on that attempt takes the | ||
| 329 | * slow path: change protection to writable per region, so a data page never gains execute, copy, | ||
| 330 | * flush the instruction cache for an executable region, restore the original protection, and | ||
| 331 | * invalidate the affected cache range. A @ref ProtectGuard held over a hot region therefore keeps | ||
| 332 | * the writes inside it on the cheap path. The slow-path copy also runs under the fault guard. A page | ||
| 333 | * reprotected or unmapped mid-copy returns `WriteMayBePartial`, and the restore and flush still run, | ||
| 334 | * with `ProtectionRestoreFailed` taking priority. A successful already-writable fast path issues no | ||
| 335 | * flush. A fast path that faults after it changed a prefix of an EXECUTABLE target is flushed before | ||
| 336 | * the fallback runs, so a protection-setup failure cannot leave modified code unflushed. | ||
| 337 | * @note A slow-path write that straddles a protection seam is handled per region: each VirtualQuery region the | ||
| 338 | * span covers is unprotected and restored to its own prior protection, so patching across a .rdata/.text | ||
| 339 | * boundary never flattens the executable region to PAGE_READONLY. A span crossing an unrealistically | ||
| 340 | * large number of distinct protection regions fails closed with `ProtectionChangeFailed`. | ||
| 341 | * @note Callback-safe on the fast path; the slow (protection-changing) path is setup/control-plane work. | ||
| 342 | */ | ||
| 343 | [[nodiscard]] Result<void> write_bytes(Address address, std::span<const std::byte> source) noexcept; | ||
| 344 | |||
| 345 | /** | ||
| 346 | * @brief Guarded write of a trivially copyable @p T to @p address. | ||
| 347 | * @tparam T A trivially copyable type. Its object representation is copied byte-for-byte; no @p T object is | ||
| 348 | * constructed at @p address. | ||
| 349 | * @param address Destination address. | ||
| 350 | * @param value Value whose object representation is written. | ||
| 351 | * @return The propagated @ref write_bytes result. | ||
| 352 | * @details Forwards to @ref write_bytes, so the same fast-path-then-unprotect policy and fault guard apply. | ||
| 353 | * @note Constrained against any non-owning view. A view argument is a compile error instead of a silent | ||
| 354 | * bit-copy of the view object. @ref detail::is_non_owning_view_v owns the rationale. | ||
| 355 | * @note Callback-safe on the fast path (see @ref write_bytes). | ||
| 356 | */ | ||
| 357 | template <class T> | ||
| 358 | requires std::is_trivially_copyable_v<T> && (!detail::is_non_owning_view_v<std::remove_cvref_t<T>>) | ||
| 359 | 8 | [[nodiscard]] Result<void> write(Address address, const T &value) noexcept | |
| 360 | { | ||
| 361 | 8 | const auto storage = std::bit_cast<std::array<std::byte, sizeof(T)>>(value); | |
| 362 | 8 | return write_bytes(address, std::span<const std::byte>{storage}); | |
| 363 | } | ||
| 364 | |||
| 365 | /** | ||
| 366 | * @brief Guarded code patch: writes @p source at @p address and flushes the instruction cache for the target. | ||
| 367 | * @param address Destination code address. | ||
| 368 | * @param source Bytes to write. Empty-span and null-target rules match @ref write_bytes. | ||
| 369 | * @return An empty `Result` on success; `NullTargetAddress` / `NullSourceBytes` / `SizeTooLarge` / | ||
| 370 | * `OverlappingRanges` (@p source intersects the target range) for a rejected argument, | ||
| 371 | * `ProtectionChangeFailed`, `WriteFaulted` (nothing was written), `WriteMayBePartial` (the changed | ||
| 372 | * prefix is indeterminate, as @ref ErrorCode::WriteMayBePartial defines), `ProtectionRestoreFailed`, or | ||
| 373 | * `InstructionFlushFailed` (the bytes landed but the flush failed). | ||
| 374 | * @details Use this route whenever the target bytes are executed as code. Every path that may modify the target | ||
| 375 | * checks an instruction-cache flush, including already-writable code and a partial guarded prefix. A | ||
| 376 | * covering flush for a partial prefix uses the full requested range before protection-changing | ||
| 377 | * fallback setup. Read-only targets are made writable without adding execute to data pages, then | ||
| 378 | * written, flushed, restored, and invalidated in the protection cache. Use @ref write_bytes or | ||
| 379 | * @ref write_in_place for data. | ||
| 380 | * @warning The write is not atomic. | ||
| 381 | * A copy that can have changed a prefix receives a full-range flush. A later retry that writes nothing | ||
| 382 | * cannot downgrade `WriteMayBePartial` to `WriteFaulted`. | ||
| 383 | * Restoration failure outranks partial-write status, which outranks a flush-only failure. | ||
| 384 | * @note Callback-safe on the fast path; the protection-changing slow path is setup/control-plane work. | ||
| 385 | */ | ||
| 386 | [[nodiscard]] Result<void> patch_code(Address address, std::span<const std::byte> source) noexcept; | ||
| 387 | |||
| 388 | /** | ||
| 389 | * @brief Strict guarded write of a byte span that NEVER changes page protection. | ||
| 390 | * @param address Destination address. | ||
| 391 | * @param source Source byte span. Empty-span and null-target rules match @ref write_bytes. | ||
| 392 | * @return An empty `Result` on success; `ErrorCode::NullTargetAddress` / `NullSourceBytes` / `SizeTooLarge` | ||
| 393 | * (over @ref MAX_WRITE_SIZE) / `OverlappingRanges` (@p source intersects the target range) for a | ||
| 394 | * rejected argument; `ErrorCode::WriteFaulted` when the target's first byte was not writable and | ||
| 395 | * nothing was written; or `ErrorCode::WriteMayBePartial` when a byte further in the span faulted after | ||
| 396 | * the copy reached a writable page. | ||
| 397 | * @warning Not atomic across a writability seam. When @p source straddles a writable page and an adjacent | ||
| 398 | * unwritable one, the copy faults and returns `ErrorCode::WriteMayBePartial`, whose changed prefix is | ||
| 399 | * indeterminate and can be empty. Size a per-frame store so it cannot straddle a protection boundary, | ||
| 400 | * or treat a `WriteMayBePartial` target as indeterminate; a `WriteFaulted` return, by contrast, | ||
| 401 | * guarantees that no byte changed. | ||
| 402 | * @details The counterpart to @ref write_bytes for memory the target already keeps writable. It does NOT | ||
| 403 | * escalate: a read-only, executable, or no-access target fails closed with `WriteFaulted` instead of | ||
| 404 | * an unprotect and a write. Use it to keep a per-frame store off the `VirtualProtect` path, or to | ||
| 405 | * make a stale pointer that lands in read-only memory surface as an error. For a one-shot code patch | ||
| 406 | * use @ref patch_code. | ||
| 407 | * @note Callback-safe: allocates nothing, takes no lock, changes no protection, and issues no syscall on the | ||
| 408 | * fast path. | ||
| 409 | */ | ||
| 410 | [[nodiscard]] Result<void> write_in_place(Address address, std::span<const std::byte> source) noexcept; | ||
| 411 | |||
| 412 | /** | ||
| 413 | * @brief Strict guarded write of a trivially copyable @p T that NEVER changes page protection. | ||
| 414 | * @tparam T A trivially copyable type; its object representation is copied byte-for-byte. | ||
| 415 | * @param address Destination address. | ||
| 416 | * @param value Value whose object representation is written. | ||
| 417 | * @return The propagated @ref write_in_place result. | ||
| 418 | * @details Forwards to @ref write_in_place, so the same no-reprotect, fail-closed-if-not-writable contract and | ||
| 419 | * seam warning apply. This is the typed per-frame store. | ||
| 420 | * @note Constrained against any non-owning view. A mutable `std::span<std::byte>` routes to the byte-span | ||
| 421 | * overload above. Any other view is a compile error instead of a silent bit-copy of the view object. | ||
| 422 | * @ref detail::is_non_owning_view_v owns the rationale. | ||
| 423 | * @note Callback-safe (see @ref write_in_place). | ||
| 424 | */ | ||
| 425 | template <class T> | ||
| 426 | requires std::is_trivially_copyable_v<T> && (!detail::is_non_owning_view_v<std::remove_cvref_t<T>>) | ||
| 427 | 4 | [[nodiscard]] Result<void> write_in_place(Address address, const T &value) noexcept | |
| 428 | { | ||
| 429 | 4 | const auto storage = std::bit_cast<std::array<std::byte, sizeof(T)>>(value); | |
| 430 | 4 | return write_in_place(address, std::span<const std::byte>{storage}); | |
| 431 | } | ||
| 432 | |||
| 433 | /** | ||
| 434 | * @struct ChainStep | ||
| 435 | * @brief One hop of a pointer-chain @ref walk: a byte offset plus the per-hop plausibility floor. | ||
| 436 | * @details A walk applies each step's @ref offset to the running address; every step except the last is then | ||
| 437 | * dereferenced to obtain the next link, and that link must be at or above @ref min_valid (and below | ||
| 438 | * @ref USERSPACE_PTR_MAX) or the walk stops. @ref min_valid is the per-hop equivalent of | ||
| 439 | * @ref is_plausible_ptr's floor, defaulting to the canonical user-mode minimum; raise it for a hop | ||
| 440 | * whose link must live above a known module base. | ||
| 441 | */ | ||
| 442 | struct ChainStep | ||
| 443 | { | ||
| 444 | /// Byte offset added to the running address at this hop (may be negative). | ||
| 445 | std::ptrdiff_t offset; | ||
| 446 | /// Lowest address the dereferenced link at this hop may hold; a link below it stops the walk. | ||
| 447 | Address min_valid = Address{USERSPACE_PTR_MIN}; | ||
| 448 | }; | ||
| 449 | |||
| 450 | /** | ||
| 451 | * @brief Resolves a multi-level pointer chain under the engine's fault guard, exposing every intermediate hop. | ||
| 452 | * @param base Root address of the chain. | ||
| 453 | * @param steps One @ref ChainStep per hop. Every offset except the last is added and dereferenced to obtain the | ||
| 454 | * next link; the final offset is added but not dereferenced, yielding the target field address. An | ||
| 455 | * empty span returns @p base unchanged. | ||
| 456 | * @param trace Optional out-buffer. When non-empty, `trace[i]` receives the value resolved at hop `i` (the | ||
| 457 | * dereferenced link for an intermediate hop, the leaf address for the final hop), for as many hops | ||
| 458 | * as fit, and is populated for the successfully-walked prefix EVEN ON PARTIAL FAILURE so a caller | ||
| 459 | * can inspect how far the chain got. | ||
| 460 | * @return The resolved leaf address on success; on failure, `ErrorCode::NullChain` for a null @p base with a | ||
| 461 | * non-empty chain, or `ErrorCode::ReadFaulted` with the FAILING HOP INDEX in `Error::detail` when an | ||
| 462 | * intermediate dereference faults or yields a link below that hop's @ref ChainStep::min_valid, or when | ||
| 463 | * the final leaf's signed-offset arithmetic wraps or lands outside [@ref USERSPACE_PTR_MIN, | ||
| 464 | * @ref USERSPACE_PTR_MAX). | ||
| 465 | * @details The walk gates each hop, captures each intermediate link, and exits at the first bad hop. It does | ||
| 466 | * not dereference the returned leaf, but it screens the leaf into | ||
| 467 | * [@ref USERSPACE_PTR_MIN, @ref USERSPACE_PTR_MAX) like every intermediate link, so a wrapped or | ||
| 468 | * non-canonical result reports a failure instead of a plausible success. The caller reads the leaf, | ||
| 469 | * usually through @ref read. | ||
| 470 | * @note Callback-safe (see @ref read_into). | ||
| 471 | */ | ||
| 472 | [[nodiscard]] Result<Address> | ||
| 473 | walk(Address base, std::span<const ChainStep> steps, std::span<Address> trace = {}) noexcept; | ||
| 474 | |||
| 475 | /** | ||
| 476 | * @brief Convenience @ref walk taking bare offsets, flooring every hop at @ref USERSPACE_PTR_MIN. | ||
| 477 | * @param base Root address of the chain. | ||
| 478 | * @param offsets Byte offsets applied left to right (see the @ref ChainStep overload for the hop semantics). | ||
| 479 | * Capped at 32 hops. Past the cap the call fails with @ref ErrorCode::SizeTooLarge (see the @note). | ||
| 480 | * @param trace Optional intermediate-capture buffer (see the @ref ChainStep overload). | ||
| 481 | * @return The resolved leaf address, or the same errors as the @ref ChainStep overload, plus | ||
| 482 | * `ErrorCode::SizeTooLarge` when @p offsets exceeds the 32-hop inline bound. | ||
| 483 | * @details The common chain shape carries no per-hop floor, so this overload accepts a plain `{0x18, 0x40}` | ||
| 484 | * offset list and applies the default plausibility floor to each dereferenced link. It is exactly the | ||
| 485 | * @ref ChainStep overload with every `min_valid` defaulted. | ||
| 486 | * @note Callback-safe (see @ref read_into): it builds the step list on a fixed 32-entry stack buffer and never | ||
| 487 | * allocates. A chain longer than 32 hops therefore fails closed with `ErrorCode::SizeTooLarge`. Route | ||
| 488 | * such a chain through the @ref ChainStep overload, whose caller owns the step storage. | ||
| 489 | */ | ||
| 490 | [[nodiscard]] Result<Address> | ||
| 491 | walk(Address base, std::span<const std::ptrdiff_t> offsets, std::span<Address> trace = {}) noexcept; | ||
| 492 | |||
| 493 | /** | ||
| 494 | * @class ProtectGuard | ||
| 495 | * @brief Move-only RAII page-protection change: applies a @ref Prot to a @ref Region and restores it on scope | ||
| 496 | * exit. | ||
| 497 | * @details Built only through @ref make, so a guard cannot exist without a successful protection change to | ||
| 498 | * unwind. Hold one over a region that is patched or written repeatedly. If the applied @ref Prot | ||
| 499 | * includes @ref Prot::W, every @ref write_bytes inside the guarded window uses the cheap no-reprotect | ||
| 500 | * fast path. Destructor restoration is best-effort. To observe the restore result, call @ref restore | ||
| 501 | * before the guard dies. | ||
| 502 | * @note The guard captures each VirtualQuery region's own prior protection across the span and restores every | ||
| 503 | * region to its own value, so a guard laid over a .rdata/.text seam does not flatten the executable | ||
| 504 | * region to PAGE_READONLY on restore. A span that crosses an unrealistically large number of distinct | ||
| 505 | * protection regions fails closed at @ref make instead of a partially-changed span. | ||
| 506 | * @note Every protection-restoring path invalidates the cached span: @ref make, the destructor, and | ||
| 507 | * move-assignment (which restores the replaced guard's own region before adopting the source) each call | ||
| 508 | * @ref invalidate_range, so the protection cache never answers a later @ref is_readable / | ||
| 509 | * @ref is_writable from a snapshot taken before the guard changed (or restored) the protection. | ||
| 510 | */ | ||
| 511 | class ProtectGuard | ||
| 512 | { | ||
| 513 | public: | ||
| 514 | /** | ||
| 515 | * @brief Changes @p region to @p protection and returns a guard that restores the prior protection. | ||
| 516 | * @param region The span whose protection is changed; an empty region fails closed. It may cross protection | ||
| 517 | * seams: each region within it is captured and restored separately (see the class notes). | ||
| 518 | * @param protection The protection to apply for the guard's lifetime. | ||
| 519 | * @return An armed guard on success; `ErrorCode::OutOfMemory` if the guard's capture state could not be | ||
| 520 | * allocated (no protection change is attempted, so nothing leaks); | ||
| 521 | * `ErrorCode::ProtectionChangeFailed` (with the OS error in `Error::extra`) if the protection could | ||
| 522 | * not be changed for a region, or the span crosses more distinct protection regions than the guard | ||
| 523 | * can track, in which case any region already changed is rolled back before returning; or | ||
| 524 | * `ErrorCode::ProtectionRestoreFailed` if that rollback itself failed, leaving a region in a | ||
| 525 | * transient protection. | ||
| 526 | * @details The capture state is allocated before any protection is changed, so a failed allocation cannot | ||
| 527 | * strand the region in the new protection with no guard to restore it. On success the changed | ||
| 528 | * range is dropped from the protection cache (@ref invalidate_range). | ||
| 529 | * @note Setup/control-plane only: the guard allocates and issues VirtualProtect syscalls. | ||
| 530 | */ | ||
| 531 | [[nodiscard]] static Result<ProtectGuard> make(Region region, Prot protection) noexcept; | ||
| 532 | |||
| 533 | ProtectGuard(ProtectGuard &&other) noexcept; | ||
| 534 | ProtectGuard &operator=(ProtectGuard &&other) noexcept; | ||
| 535 | ProtectGuard(const ProtectGuard &) = delete; | ||
| 536 | ProtectGuard &operator=(const ProtectGuard &) = delete; | ||
| 537 | |||
| 538 | /// Restores the original page protection unless the guard was moved-from or @ref release was called. | ||
| 539 | ~ProtectGuard() noexcept; | ||
| 540 | |||
| 541 | /// True while the guard is armed (it will restore on destruction); false after a move or @ref release. | ||
| 542 | [[nodiscard]] explicit operator bool() const noexcept; | ||
| 543 | |||
| 544 | /** | ||
| 545 | * @brief Disarms the guard. Its destructor then leaves the changed protection in place. | ||
| 546 | * @details The page entry leaves the ledger once no other guard holds that page, so the next guard over | ||
| 547 | * it captures the current protection. While another guard still holds the page, this guard's | ||
| 548 | * applied protection becomes that guard's restore baseline. | ||
| 549 | * @note Setup/control-plane only: ledger removal takes the protection ledger lock. | ||
| 550 | */ | ||
| 551 | void release() noexcept; | ||
| 552 | |||
| 553 | /** | ||
| 554 | * @brief Restores the original protection now, reports the result, and disarms the guard. | ||
| 555 | * @return An empty `Result` on success; `ErrorCode::ProtectionRestoreFailed` (OS error in `Error::extra`) | ||
| 556 | * when a region could not be restored. A moved-from, released, or already-restored guard returns | ||
| 557 | * success. There is nothing left to restore. | ||
| 558 | * @details The observable counterpart to the best-effort destructor. Idempotent: it disarms the guard, so | ||
| 559 | * the destructor then does nothing. On failure the guard is still disarmed, and the range is | ||
| 560 | * dropped from the protection cache exactly as the destructor does. | ||
| 561 | * @note Setup/control-plane only: the restore issues VirtualProtect syscalls. | ||
| 562 | */ | ||
| 563 | [[nodiscard]] Result<void> restore() noexcept; | ||
| 564 | |||
| 565 | private: | ||
| 566 | // Private so the only way to obtain a guard is make(), which guarantees the protection change succeeded. | ||
| 567 | ProtectGuard() noexcept; | ||
| 568 | |||
| 569 | // The captured base/size/old-protection live in the engine TU so this header carries no Win32 type. | ||
| 570 | struct Impl; | ||
| 571 | std::unique_ptr<Impl> m_impl; | ||
| 572 | }; | ||
| 573 | |||
| 574 | /** | ||
| 575 | * @brief Resolves the mapped image span of the module that owns @p address. | ||
| 576 | * @param address Any address inside the target module. | ||
| 577 | * @return The owning module's @ref Region, or an empty Region when @p address is null, falls inside no loaded | ||
| 578 | * module, or the module's PE headers do not validate. | ||
| 579 | * @details Every call reports the extent the image currently publishes, so a module replaced at the same | ||
| 580 | * base is never answered from the previous image's headers. | ||
| 581 | * @note Setup/control-plane only: the call issues a loader lookup and a guarded PE-header read. | ||
| 582 | * @warning The returned Region is a non-owning scope. It does not pin the module, so a module unloaded after | ||
| 583 | * this returns leaves a span that references freed address space. | ||
| 584 | */ | ||
| 585 | [[nodiscard]] Region module_of(Address address) noexcept; | ||
| 586 | |||
| 587 | /** | ||
| 588 | * @brief Reports whether a module with the given base name is currently loaded in the process. | ||
| 589 | * @param basename The module's file name as the loader knows it (e.g. "kernel32.dll"); a bare name, not a path. | ||
| 590 | * @param case_insensitive When true (the default, matching Windows module-name semantics) the comparison | ||
| 591 | * ignores case. | ||
| 592 | * @return True when a loaded module's base name matches @p basename. | ||
| 593 | * A path longer than `MAX_PATH` does not change either answer. | ||
| 594 | * @note Setup/control-plane only: the query reaches the loader. An exact-case request fails closed under the | ||
| 595 | * loader lock, because it requires a counted module reference. | ||
| 596 | */ | ||
| 597 | [[nodiscard]] bool is_module_loaded(std::string_view basename, bool case_insensitive = true) noexcept; | ||
| 598 | |||
| 599 | /** | ||
| 600 | * @struct MemoryStats | ||
| 601 | * @brief Allocation-free snapshot of protection-cache configuration and counters. | ||
| 602 | * @details Every field mirrors a value reported by @ref get_cache_stats. Counters are loaded with relaxed | ||
| 603 | * atomics and the live-entry totals are summed under the shard reader guard, so the struct is a | ||
| 604 | * consistent-per-field but not globally-atomic view. @ref hit_rate_percent is -1.0 when no queries | ||
| 605 | * have been tracked (hits + misses == 0). | ||
| 606 | */ | ||
| 607 | struct MemoryStats | ||
| 608 | { | ||
| 609 | /// Configured number of cache shards. | ||
| 610 | std::size_t shard_count = 0; | ||
| 611 | /// Configured soft entry capacity per shard. | ||
| 612 | std::size_t max_entries_per_shard = 0; | ||
| 613 | /// Hard maximum entries per shard (capacity * multiplier), averaged across shards. | ||
| 614 | std::size_t hard_max_per_shard = 0; | ||
| 615 | /// Cache-entry expiry window in milliseconds. | ||
| 616 | unsigned int expiry_ms = 0; | ||
| 617 | /// Cumulative cache hits. | ||
| 618 | std::uint64_t hits = 0; | ||
| 619 | /// Cumulative cache misses. | ||
| 620 | std::uint64_t misses = 0; | ||
| 621 | /// Cumulative range invalidations. | ||
| 622 | std::uint64_t invalidations = 0; | ||
| 623 | /// Cumulative in-flight query coalesces. | ||
| 624 | std::uint64_t coalesced_queries = 0; | ||
| 625 | /// Cumulative on-demand cleanup passes. | ||
| 626 | std::uint64_t on_demand_cleanups = 0; | ||
| 627 | /// Live entry count summed across all shards at snapshot time. | ||
| 628 | std::size_t total_entries = 0; | ||
| 629 | /// hits / (hits + misses) * 100, or -1.0 when no queries have been tracked. | ||
| 630 | double hit_rate_percent = -1.0; | ||
| 631 | /** | ||
| 632 | * @brief Sticky count of lifecycle-invariant violations recovered without terminating. | ||
| 633 | * @details Includes an unexpected joinable handle before start and any contained join/detach failure. | ||
| 634 | * Monotonic; never reset by clear or shutdown, and expected to remain zero in normal operation. | ||
| 635 | */ | ||
| 636 | std::uint64_t lifecycle_violations = 0; | ||
| 637 | }; | ||
| 638 | |||
| 639 | /** | ||
| 640 | * @brief Initializes the protection-region cache used by @ref is_readable / @ref is_writable. | ||
| 641 | * @param cache_size Desired number of entries across the cache. | ||
| 642 | * @param expiry_ms Cache entry expiry time in milliseconds. | ||
| 643 | * @param shard_count Number of cache shards for concurrent access. | ||
| 644 | * @return True if the cache is ready for use. | ||
| 645 | * False if lifecycle state blocks a start or cache setup fails. | ||
| 646 | * A false return leaves the cache stopped, so readers use the uncached `VirtualQuery` route. | ||
| 647 | * @details A call while the cache is running returns true and keeps the running configuration, with no | ||
| 648 | * reconfiguration and no loader-lock check. | ||
| 649 | * A call after @ref shutdown_cache starts a fresh cache with the arguments of that call. A start fails | ||
| 650 | * if readers from a prior session do not exit before the drain deadline. It retains that session's | ||
| 651 | * storage and precommitted module reference. | ||
| 652 | * A successful start creates the cleanup thread when the platform permits it. | ||
| 653 | * Otherwise, the cache uses on-demand cleanup. | ||
| 654 | * MinGW also installs the process fault handler for guarded reads. | ||
| 655 | * @note Setup/control-plane only. Every cache setup failure appears in the return value. | ||
| 656 | */ | ||
| 657 | [[nodiscard]] bool init_cache( | ||
| 658 | std::size_t cache_size = DEFAULT_CACHE_SIZE, | ||
| 659 | unsigned int expiry_ms = DEFAULT_CACHE_EXPIRY_MS, | ||
| 660 | std::size_t shard_count = DEFAULT_CACHE_SHARD_COUNT | ||
| 661 | ); | ||
| 662 | |||
| 663 | /** | ||
| 664 | * @brief Clears all entries from the protection cache, leaving it initialized. | ||
| 665 | * @details Invalidates all cached region information; the background cleanup thread keeps running. | ||
| 666 | * @note Setup/control-plane only: the clear takes every shard's exclusive lock. | ||
| 667 | */ | ||
| 668 | void clear_cache() noexcept; | ||
| 669 | |||
| 670 | /** | ||
| 671 | * @brief Shuts the cache down and joins the background cleanup thread. | ||
| 672 | * @details Call before module unload to terminate the cleanup thread cleanly. After shutdown, the cache cannot | ||
| 673 | * be reused without re-initialization. Under loader lock the thread is detached rather than joined to | ||
| 674 | * avoid deadlock, and on MinGW the vectored fault handler is drained and removed. | ||
| 675 | * Teardown closes reader admission first. A later permission query takes the uncached `VirtualQuery` | ||
| 676 | * route. The wait for admitted readers has a fixed deadline. The cache precommits a module reference | ||
| 677 | * before admission opens. On expiry it retains that reference and the cache storage. It also records | ||
| 678 | * one @ref diagnostics::LeakSubsystem::MemoryCache event. A later @ref init_cache or | ||
| 679 | * @ref shutdown_cache call can reclaim the storage after the stalled reader exits. A clean shutdown | ||
| 680 | * releases the cache reference. | ||
| 681 | * @note Setup/control-plane only. | ||
| 682 | */ | ||
| 683 | void shutdown_cache() noexcept; | ||
| 684 | |||
| 685 | /** | ||
| 686 | * @brief Returns an allocation-free snapshot of cache statistics. | ||
| 687 | * @return A @ref MemoryStats snapshot. | ||
| 688 | */ | ||
| 689 | [[nodiscard]] MemoryStats get_memory_stats() noexcept; | ||
| 690 | |||
| 691 | /** | ||
| 692 | * @brief Returns a human-readable string of cache statistics, built over @ref get_memory_stats. | ||
| 693 | * @return A formatted statistics string. Prefer @ref get_memory_stats for telemetry consumers. | ||
| 694 | */ | ||
| 695 | [[nodiscard]] std::string get_cache_stats(); | ||
| 696 | |||
| 697 | /** | ||
| 698 | * @brief Invalidates cache entries overlapping @p range, forcing a re-query on the next probe. | ||
| 699 | * @param range The span whose cached protection state is dropped. An empty range is a no-op. | ||
| 700 | * @details Used after external protection changes (a VirtualProtect by other code) so a later @ref is_readable | ||
| 701 | * does not answer from stale protection. @ref write_bytes performs this automatically on its | ||
| 702 | * protection-changing slow path. | ||
| 703 | * @note Setup/control-plane only: the invalidation mutates the cache shards. | ||
| 704 | */ | ||
| 705 | void invalidate_range(Region range) noexcept; | ||
| 706 | |||
| 707 | /** | ||
| 708 | * @enum ReadableStatus | ||
| 709 | * @brief Tri-state result for the non-blocking readability check. | ||
| 710 | */ | ||
| 711 | enum class ReadableStatus : std::uint8_t | ||
| 712 | { | ||
| 713 | /// The region is committed and readable. | ||
| 714 | Readable, | ||
| 715 | /// The region is not committed, not readable, or the arguments were rejected. | ||
| 716 | NotReadable, | ||
| 717 | /** | ||
| 718 | * @brief Reports that a wait is required before the check can produce a result. | ||
| 719 | * @details This value arises only while the cache runs, in these cases: | ||
| 720 | * - The shard lock is contended. | ||
| 721 | * - The cache misses. | ||
| 722 | * - A concurrent shutdown unpublished the shards. | ||
| 723 | */ | ||
| 724 | Unknown | ||
| 725 | }; | ||
| 726 | |||
| 727 | /** | ||
| 728 | * @brief Reports whether @p range is committed and readable. | ||
| 729 | * @param range The span to check. An empty range returns false. | ||
| 730 | * @return True when the entire range is readable and committed. | ||
| 731 | * @warning On a per-dereference hot path, do not use this function. A hit takes a shard reader lock. A miss can | ||
| 732 | * walk the range's regions with one VirtualQuery per region. The answer is a time-of-check/time-of-use | ||
| 733 | * snapshot. For hot game-owned reads, a guarded @ref read provides a checked `Result`. An optional | ||
| 734 | * @ref is_plausible_ptr call can pre-screen the address. | ||
| 735 | * @note Setup/control-plane only: see the hot-path warning above; a latency-sensitive caller uses | ||
| 736 | * @ref is_readable_nonblocking. | ||
| 737 | */ | ||
| 738 | [[nodiscard]] bool is_readable(Region range) noexcept; | ||
| 739 | |||
| 740 | /** | ||
| 741 | * @brief Reports whether @p range is committed and writable. | ||
| 742 | * @param range The span to check. An empty range returns false. | ||
| 743 | * @return True when the entire range is writable and committed. | ||
| 744 | * @warning Carries the same hot-path cost and time-of-check/time-of-use caveat as @ref is_readable; reserve it | ||
| 745 | * for one-shot setup validation. To write, prefer attempting a guarded @ref write_bytes which fails | ||
| 746 | * closed. | ||
| 747 | * @note Setup/control-plane only (see @ref is_readable). | ||
| 748 | */ | ||
| 749 | [[nodiscard]] bool is_writable(Region range) noexcept; | ||
| 750 | |||
| 751 | /** | ||
| 752 | * @brief Non-blocking readability check that returns @ref ReadableStatus::Unknown rather than stalling. | ||
| 753 | * @param range The span to check. An empty range returns @ref ReadableStatus::NotReadable. | ||
| 754 | * @return @ref ReadableStatus::Readable / NotReadable for a definite answer, or @ref ReadableStatus::Unknown | ||
| 755 | * when answering would require blocking (a contended shard try-lock or a cache miss, while the cache | ||
| 756 | * runs), so a latency-sensitive caller can fall back to a guarded @ref read instead of stalling. | ||
| 757 | * @details While the cache is not in its running state (before @ref init_cache, during initialization or | ||
| 758 | * shutdown, or after @ref shutdown_cache), there is no cache to consult. The check then falls back | ||
| 759 | * to a blocking range walk with one VirtualQuery per region and returns a definite answer, never | ||
| 760 | * Unknown. | ||
| 761 | * @note Callback-safe while the cache runs: a try-lock probe with no allocation. Outside the running state it | ||
| 762 | * takes the blocking fallback above. | ||
| 763 | */ | ||
| 764 | [[nodiscard]] ReadableStatus is_readable_nonblocking(Region range) noexcept; | ||
| 765 | |||
| 766 | /** | ||
| 767 | * @namespace DetourModKit::memory::unchecked | ||
| 768 | * @brief The raw, validation-free fast path. Every entry point here FAULTS THE HOST on an unreadable byte. | ||
| 769 | * @details Quarantined in its own namespace so the danger is visible at the call site: nothing here guards, | ||
| 770 | * gates, or reports an error, because the contract is "the caller has already proven this access is | ||
| 771 | * safe". | ||
| 772 | */ | ||
| 773 | namespace unchecked | ||
| 774 | { | ||
| 775 | /** | ||
| 776 | * @brief Unguarded typed read of a representation-safe @p T at @p address. | ||
| 777 | * @tparam T A trivially copyable type in the representation-safe domain | ||
| 778 | * (@ref detail::is_representation_safe_v), the same gate the guarded @ref read applies. This | ||
| 779 | * route has no error channel at all, so the domain is enforced purely at compile time; decode | ||
| 780 | * `bool` through the guarded @ref read_bool. | ||
| 781 | * @param address Source address. EVERY byte of `[address, address + sizeof(T))` MUST be committed and | ||
| 782 | * readable; this performs NO validation and a violation faults the host process. | ||
| 783 | * @return The value at @p address. A top-level bounded built-in array is returned as the equivalent nested | ||
| 784 | * `std::array`, because C++ functions cannot return a built-in array by value. | ||
| 785 | * @details Under `NDEBUG`, this is a single inlined copy with no SEH, `VirtualQuery`, or cache lookup. A | ||
| 786 | * Debug build first evaluates `assert(is_readable(...))`, which can take a shard lock or call | ||
| 787 | * `VirtualQuery`. Use it only for pointers that the caller proves are live for the current frame. | ||
| 788 | * For anything that can be stale, use the guarded @ref read. | ||
| 789 | * @note Callback-safe under `NDEBUG`: it does nothing but copy. A Debug build can block or call | ||
| 790 | * `VirtualQuery` during the assertion. | ||
| 791 | * @warning Under `NDEBUG`, an invalid address faults the host. A Debug build stops at the assertion. | ||
| 792 | */ | ||
| 793 | template <class T> | ||
| 794 | requires(std::is_trivially_copyable_v<T> && detail::is_representation_safe_v<T>) | ||
| 795 | 16 | [[nodiscard]] detail::representation_read_value_t<T> read(Address address) noexcept | |
| 796 | { | ||
| 797 | // The is_readable() probe must not survive into Release. assert() discards it under NDEBUG. | ||
| 798 |
2/4DetourModKit::detail::representation_read_value<int [2]>::type DetourModKit::memory::unchecked::read<int [2]>(DetourModKit::Address):
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 1 time.
DetourModKit::detail::representation_read_value<unsigned long long>::type DetourModKit::memory::unchecked::read<unsigned long long>(DetourModKit::Address):
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 15 times.
|
16 | assert( |
| 799 | is_readable(Region{address, sizeof(T)}) && | ||
| 800 | "unchecked::read<T>: address is not fully readable; the caller's safety precondition is violated" | ||
| 801 | ); | ||
| 802 | 16 | std::array<std::byte, sizeof(T)> storage{}; | |
| 803 | 16 | std::memcpy(storage.data(), address.as<const void *>(), sizeof(T)); | |
| 804 | 16 | return detail::decode_foreign_representation<T>(storage); | |
| 805 | } | ||
| 806 | } // namespace unchecked | ||
| 807 | } // namespace memory | ||
| 808 | } // namespace DetourModKit | ||
| 809 | |||
| 810 | #endif // DETOURMODKIT_MEMORY_HPP | ||
| 811 |