include/DetourModKit/scanner.hpp
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | #ifndef DETOURMODKIT_SCANNER_HPP | ||
| 2 | #define DETOURMODKIT_SCANNER_HPP | ||
| 3 | |||
| 4 | #include "DetourModKit/memory.hpp" | ||
| 5 | |||
| 6 | #include <array> | ||
| 7 | #include <vector> | ||
| 8 | #include <string> | ||
| 9 | #include <string_view> | ||
| 10 | #include <cstddef> | ||
| 11 | #include <cstdint> | ||
| 12 | #include <expected> | ||
| 13 | #include <limits> | ||
| 14 | #include <optional> | ||
| 15 | #include <span> | ||
| 16 | |||
| 17 | namespace DetourModKit | ||
| 18 | { | ||
| 19 | /** | ||
| 20 | * @enum RipResolveError | ||
| 21 | * @brief Error codes for RIP-relative resolution failures. | ||
| 22 | */ | ||
| 23 | enum class RipResolveError | ||
| 24 | { | ||
| 25 | NullInput, | ||
| 26 | PrefixNotFound, | ||
| 27 | RegionTooSmall, | ||
| 28 | UnreadableDisplacement, | ||
| 29 | ImplausibleTarget | ||
| 30 | }; | ||
| 31 | |||
| 32 | /** | ||
| 33 | * @brief Converts a RipResolveError to a human-readable string. | ||
| 34 | * @param error The error code. | ||
| 35 | * @return A string view describing the error. | ||
| 36 | */ | ||
| 37 | 4 | [[nodiscard]] constexpr std::string_view rip_resolve_error_to_string(RipResolveError error) noexcept | |
| 38 | { | ||
| 39 |
4/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 not taken.
✗ Branch 2 → 8 not taken.
|
4 | switch (error) |
| 40 | { | ||
| 41 | 1 | case RipResolveError::NullInput: | |
| 42 | 1 | return "Null input pointer"; | |
| 43 | 1 | case RipResolveError::PrefixNotFound: | |
| 44 | 1 | return "Opcode prefix not found in search region"; | |
| 45 | 1 | case RipResolveError::RegionTooSmall: | |
| 46 | 1 | return "Search region too small for prefix + displacement"; | |
| 47 | 1 | case RipResolveError::UnreadableDisplacement: | |
| 48 | 1 | return "Displacement bytes at matched location are not readable"; | |
| 49 | ✗ | case RipResolveError::ImplausibleTarget: | |
| 50 | ✗ | return "Resolved target is not a plausible user-mode address"; | |
| 51 | ✗ | default: | |
| 52 | ✗ | return "Unknown RIP resolve error"; | |
| 53 | } | ||
| 54 | } | ||
| 55 | |||
| 56 | namespace Scanner | ||
| 57 | { | ||
| 58 | /** | ||
| 59 | * @struct CompiledPattern | ||
| 60 | * @brief A pre-compiled AOB pattern with separate bytes and mask. | ||
| 61 | * @details Stores the pattern bytes and a bitmask indicating which bytes are wildcards (mask=false) vs. literal | ||
| 62 | * values to match (mask=true). This design avoids sentinel byte conflicts (e.g., 0xCC is a valid | ||
| 63 | * byte). | ||
| 64 | */ | ||
| 65 | struct CompiledPattern | ||
| 66 | { | ||
| 67 | /** | ||
| 68 | * @brief Pattern bytes, one per token in the source AOB string. | ||
| 69 | * @details Each entry is pre-masked to its known bits: a wildcard position (mask 0x00) holds 0, and a | ||
| 70 | * partially-masked nibble position holds its known nibble with the wildcard nibble zeroed. A plain | ||
| 71 | * (memory_byte ^ bytes) & mask compare is therefore correct at every position without | ||
| 72 | * special-casing the wildcard slots. | ||
| 73 | */ | ||
| 74 | std::vector<std::byte> bytes; | ||
| 75 | |||
| 76 | /** | ||
| 77 | * @brief Per-byte match mask paralleling @ref bytes. | ||
| 78 | * @details The mask selects which bits of each byte must match: a position passes when | ||
| 79 | * (memory_byte ^ @ref bytes) & mask == 0. 0xFF marks a fully-literal byte that must match exactly, | ||
| 80 | * 0x00 marks a wildcard slot to skip, and 0xF0 / 0x0F mark a per-nibble wildcard (a high-nibble or | ||
| 81 | * low-nibble token such as "4?" or "?5") where only the masked nibble must match. Sized | ||
| 82 | * identically to @ref bytes. | ||
| 83 | */ | ||
| 84 | std::vector<std::byte> mask; | ||
| 85 | |||
| 86 | /** | ||
| 87 | * @brief Byte offset from pattern start to the point of interest. | ||
| 88 | * @details Set by the `|` marker in the AOB string, or 0 if absent. | ||
| 89 | * May equal bytes.size() when `|` appears at the end of the | ||
| 90 | * pattern. The offset is non-negative under the current | ||
| 91 | * parser (`|` cannot precede tokens), but the type is | ||
| 92 | * signed to match pointer-arithmetic conventions (C++ Core Guidelines ES.106) and to future-proof | ||
| 93 | * against negative anchors. | ||
| 94 | */ | ||
| 95 | std::ptrdiff_t offset = 0; | ||
| 96 | |||
| 97 | /** | ||
| 98 | * @brief Cached anchor index selected by compile_anchor(). | ||
| 99 | * @details find_pattern() drives its memchr sweep on the byte at this position. The index is the rarest | ||
| 100 | * literal byte in the pattern (lowest score in a small frequency table tuned for typical x64 .text | ||
| 101 | * sections), so a single memchr pass produces far fewer false candidate hits than anchoring on | ||
| 102 | * `bytes[0]` would. | ||
| 103 | * | ||
| 104 | * Sentinel values: | ||
| 105 | * - `[0, size())` valid anchor. | ||
| 106 | * - `size()` pattern has no fully-known byte to anchor on (all wildcards, or only | ||
| 107 | * nibble constraints); scan degenerates to "match at start" for an | ||
| 108 | * all-wildcard pattern, or a masked compare at every position when only | ||
| 109 | * nibble constraints remain. | ||
| 110 | * - `>= size() + 1` anchor not yet selected; | ||
| 111 | * find_pattern() will pick one inline (slower path). | ||
| 112 | * | ||
| 113 | * parse_aob() always calls compile_anchor() before returning, so patterns produced through the | ||
| 114 | * public API enter find_pattern() with the cached anchor in place. Manually constructed patterns | ||
| 115 | * (assigning `bytes`/`mask` by hand) start in the "not yet selected" state and should call @ref | ||
| 116 | * compile_anchor() once after population if they will be scanned repeatedly. | ||
| 117 | */ | ||
| 118 | std::size_t anchor = std::numeric_limits<std::size_t>::max(); | ||
| 119 | |||
| 120 | /** | ||
| 121 | * @brief Returns the size of the pattern. | ||
| 122 | * @return size_t The number of bytes in the pattern. | ||
| 123 | */ | ||
| 124 | 4626805 | [[nodiscard]] size_t size() const noexcept { return bytes.size(); } | |
| 125 | |||
| 126 | /** | ||
| 127 | * @brief Checks if the pattern is empty. | ||
| 128 | * @return true if the pattern has no bytes. | ||
| 129 | */ | ||
| 130 | 7915 | [[nodiscard]] bool empty() const noexcept { return bytes.empty(); } | |
| 131 | |||
| 132 | /** | ||
| 133 | * @brief Selects and stores the rarest fully-known byte's index as the scan anchor. | ||
| 134 | * @details Walks the pattern once, scoring each fully-known (mask 0xFF) byte against a small byte-frequency | ||
| 135 | * table (`0x00`, `0xCC`, `0x48`, ... receive high scores; uncommon bytes score 0), and stores the | ||
| 136 | * lowest-scoring index in @ref anchor. Partially-masked nibble bytes are skipped: the prefilter | ||
| 137 | * needs one exact byte value to scan for, which a nibble does not provide. Ties are broken by | ||
| 138 | * first occurrence for deterministic behaviour. A pattern with no fully-known byte sets @ref | ||
| 139 | * anchor to `size()`; find_pattern() then either takes the degenerate "match at region start" path | ||
| 140 | * (an all-wildcard pattern) or, when only nibble constraints remain, a masked compare at every | ||
| 141 | * position. | ||
| 142 | * | ||
| 143 | * Safe to call repeatedly; the operation is idempotent and O(size()). Callers that mutate @ref | ||
| 144 | * bytes or @ref mask after a prior compile_anchor() MUST call it again before the next scan or the | ||
| 145 | * cached anchor will drift from the pattern contents. | ||
| 146 | * | ||
| 147 | * Not thread-safe with concurrent find_pattern() calls on the same CompiledPattern instance; | ||
| 148 | * sequence the compile step before publishing the pattern to scanners. | ||
| 149 | */ | ||
| 150 | void compile_anchor() noexcept; | ||
| 151 | }; | ||
| 152 | |||
| 153 | /** | ||
| 154 | * @brief Parses a space-separated AOB string into a compiled pattern. | ||
| 155 | * @details Converts hexadecimal byte tokens (e.g. "48") to literal byte values, full-wildcard tokens ('??' or | ||
| 156 | * '?') to skip slots, and per-nibble tokens ("4?" with a known high nibble, "?5" with a known low | ||
| 157 | * nibble) to partially-masked bytes. An optional `|` token marks the offset within the pattern (stored | ||
| 158 | * in CompiledPattern::offset). This lets wider patterns precisely target a specific instruction: e.g., | ||
| 159 | * "48 8B 88 B8 00 00 00 | 48 89 4C 24 68" sets offset=7, and "48 8B ?D" matches any ModRM byte whose | ||
| 160 | * low nibble is D. | ||
| 161 | * @param aob_str The AOB pattern string. | ||
| 162 | * @return std::optional<CompiledPattern> The compiled pattern, or std::nullopt on parse failure. | ||
| 163 | */ | ||
| 164 | [[nodiscard]] std::optional<CompiledPattern> parse_aob(std::string_view aob_str); | ||
| 165 | |||
| 166 | /** | ||
| 167 | * @brief Scans a specified memory region for a given byte pattern. | ||
| 168 | * @details Uses an optimized search algorithm that finds the first non-wildcard byte and uses memchr for fast | ||
| 169 | * skipping, then verifies the full pattern. | ||
| 170 | * @param start_address Pointer to the beginning of the memory region to scan. | ||
| 171 | * @param region_size The size (in bytes) of the memory region to scan. | ||
| 172 | * @param pattern The compiled pattern to search for. | ||
| 173 | * @return const std::byte* Pointer to the match within the specified region, already adjusted by | ||
| 174 | * `pattern.offset`. Returns nullptr if pattern not found. | ||
| 175 | * @note A pattern with zero literal bytes (every token wildcarded) returns `start_address` (plus offset) and | ||
| 176 | * emits a warning through the shared | ||
| 177 | * Logger. This case almost always indicates a caller bug; the behaviour is preserved for backwards | ||
| 178 | * compatibility but should not be relied upon. | ||
| 179 | * @note `pattern.offset` (set by a `|` marker in the AOB string) is applied | ||
| 180 | * exactly once. When no marker is present `offset == 0` and the returned pointer is the match start. | ||
| 181 | * Callers must NOT add `pattern.offset` manually; doing so double-applies and will miss the intended | ||
| 182 | * byte. | ||
| 183 | * @warning When `pattern.offset == pattern.size()` (a trailing `|` marker), | ||
| 184 | * the returned pointer addresses one-past the matched range. Depending on where in the region the | ||
| 185 | * match landed, this may also be one-past the scanned region. The pointer is valid for arithmetic and | ||
| 186 | * bounds comparisons but MUST NOT be dereferenced without an explicit readability check (e.g. | ||
| 187 | * `Memory::is_readable`). | ||
| 188 | * @warning READABLE-RANGE PRECONDITION: this raw overload performs no page filtering. The caller MUST guarantee | ||
| 189 | * the entire span `[start_address, start_address + region_size)` is committed and readable, because | ||
| 190 | * the search reads it with raw `memchr`/SIMD loads and an unreadable byte faults the host. Use it only | ||
| 191 | * on byte buffers or module sections whose readability is already known. To scan arbitrary process or | ||
| 192 | * module memory, prefer the page-gated helpers (`scan_executable_regions`, `scan_readable_regions`) or | ||
| 193 | * the module-scoped cascade (`resolve_cascade_in_module`) which walk `VirtualQuery` and skip guard, | ||
| 194 | * no-access, and non-readable pages. | ||
| 195 | */ | ||
| 196 | [[nodiscard]] const std::byte *find_pattern(const std::byte *start_address, size_t region_size, | ||
| 197 | const CompiledPattern &pattern); | ||
| 198 | |||
| 199 | /** | ||
| 200 | * @brief std::span convenience overload of the single-occurrence raw scan. | ||
| 201 | * @details Delegates to find_pattern(region.data(), region.size(), pattern). An empty span yields nullptr. | ||
| 202 | * Carries the same READABLE-RANGE PRECONDITION as the pointer+size overload: the entire span must be | ||
| 203 | * committed and readable, because the search uses raw memchr/SIMD loads. | ||
| 204 | * @param region Contiguous, committed, readable byte span to scan. | ||
| 205 | * @param pattern The compiled pattern to search for. | ||
| 206 | * @return Pointer to the match within @p region (adjusted by pattern.offset), or nullptr if not found. | ||
| 207 | */ | ||
| 208 | 2 | [[nodiscard]] inline const std::byte *find_pattern(std::span<const std::byte> region, | |
| 209 | const CompiledPattern &pattern) | ||
| 210 | { | ||
| 211 | 2 | return find_pattern(region.data(), region.size(), pattern); | |
| 212 | } | ||
| 213 | |||
| 214 | /** | ||
| 215 | * @brief Scans a memory region for the Nth occurrence of a byte pattern. | ||
| 216 | * @param start_address Pointer to the beginning of the memory region to scan. | ||
| 217 | * @param region_size The size (in bytes) of the memory region to scan. | ||
| 218 | * @param pattern The compiled pattern to search for. | ||
| 219 | * @param occurrence Which occurrence to return (1-based). 1 = first match. Passing 0 returns nullptr. | ||
| 220 | * @return const std::byte* Pointer to the Nth occurrence (already adjusted by `pattern.offset`), or nullptr if | ||
| 221 | * fewer than N matches exist. | ||
| 222 | * @note Like the single-occurrence overload, `pattern.offset` is applied exactly once. Callers must NOT add it | ||
| 223 | * manually. | ||
| 224 | * @warning A trailing `|` marker produces a one-past pointer identical in | ||
| 225 | * kind to the single-occurrence overload; do not dereference without a bounds or readability check. | ||
| 226 | * @warning READABLE-RANGE PRECONDITION: like the single-occurrence overload, this raw overload performs no page | ||
| 227 | * filtering. The caller MUST guarantee the entire span `[start_address, start_address + region_size)` | ||
| 228 | * is committed and readable; the scan uses raw `memchr`/SIMD loads and an unreadable byte faults the | ||
| 229 | * host. For arbitrary process or module memory, prefer the page-gated helpers | ||
| 230 | * (`scan_executable_regions`, `scan_readable_regions`) or the module-scoped cascade | ||
| 231 | * (`resolve_cascade_in_module`). | ||
| 232 | */ | ||
| 233 | [[nodiscard]] const std::byte *find_pattern(const std::byte *start_address, size_t region_size, | ||
| 234 | const CompiledPattern &pattern, size_t occurrence); | ||
| 235 | |||
| 236 | /** | ||
| 237 | * @brief std::span convenience overload of the Nth-occurrence raw scan. | ||
| 238 | * @details Delegates to find_pattern(region.data(), region.size(), pattern, occurrence). Same READABLE-RANGE | ||
| 239 | * PRECONDITION as the pointer+size overload: the entire span must be committed and readable. | ||
| 240 | * @param region Contiguous, committed, readable byte span to scan. | ||
| 241 | * @param pattern The compiled pattern to search for. | ||
| 242 | * @param occurrence Which occurrence to return (1-based). Passing 0 returns nullptr. | ||
| 243 | * @return Pointer to the Nth occurrence (adjusted by pattern.offset), or nullptr if fewer than N matches. | ||
| 244 | */ | ||
| 245 | 1 | [[nodiscard]] inline const std::byte *find_pattern(std::span<const std::byte> region, | |
| 246 | const CompiledPattern &pattern, size_t occurrence) | ||
| 247 | { | ||
| 248 | 1 | return find_pattern(region.data(), region.size(), pattern, occurrence); | |
| 249 | } | ||
| 250 | |||
| 251 | /// Common x86-64 RIP-relative opcode prefixes (bytes preceding the disp32 field). | ||
| 252 | inline constexpr std::array<std::byte, 3> PREFIX_MOV_RAX_RIP = {std::byte{0x48}, std::byte{0x8B}, | ||
| 253 | std::byte{0x05}}; | ||
| 254 | inline constexpr std::array<std::byte, 3> PREFIX_MOV_RCX_RIP = {std::byte{0x48}, std::byte{0x8B}, | ||
| 255 | std::byte{0x0D}}; | ||
| 256 | inline constexpr std::array<std::byte, 3> PREFIX_MOV_RDX_RIP = {std::byte{0x48}, std::byte{0x8B}, | ||
| 257 | std::byte{0x15}}; | ||
| 258 | inline constexpr std::array<std::byte, 3> PREFIX_MOV_RBX_RIP = {std::byte{0x48}, std::byte{0x8B}, | ||
| 259 | std::byte{0x1D}}; | ||
| 260 | inline constexpr std::array<std::byte, 3> PREFIX_LEA_RAX_RIP = {std::byte{0x48}, std::byte{0x8D}, | ||
| 261 | std::byte{0x05}}; | ||
| 262 | inline constexpr std::array<std::byte, 3> PREFIX_LEA_RCX_RIP = {std::byte{0x48}, std::byte{0x8D}, | ||
| 263 | std::byte{0x0D}}; | ||
| 264 | inline constexpr std::array<std::byte, 3> PREFIX_LEA_RDX_RIP = {std::byte{0x48}, std::byte{0x8D}, | ||
| 265 | std::byte{0x15}}; | ||
| 266 | inline constexpr std::array<std::byte, 1> PREFIX_CALL_REL32 = {std::byte{0xE8}}; | ||
| 267 | inline constexpr std::array<std::byte, 1> PREFIX_JMP_REL32 = {std::byte{0xE9}}; | ||
| 268 | |||
| 269 | /** | ||
| 270 | * @brief Resolves an absolute address from an x86-64 RIP-relative instruction. | ||
| 271 | * @details Extracts the int32 displacement at the given offset within the instruction and computes the absolute | ||
| 272 | * target: instruction_address + instruction_length + displacement. | ||
| 273 | * @param instruction_address Pointer to the first byte of the instruction. | ||
| 274 | * @param displacement_offset Byte offset from instruction_address to the disp32 field. | ||
| 275 | * @param instruction_length Total length of the instruction in bytes. | ||
| 276 | * @return The resolved absolute address, or RipResolveError on failure. | ||
| 277 | * @note The displacement is read under an SEH fault guard. A resolved address that is not a plausible user-mode | ||
| 278 | * pointer (a crafted or corrupt displacement that resolves to 0, a low guard-page address, or a | ||
| 279 | * kernel-range address) is rejected with | ||
| 280 | * RipResolveError::ImplausibleTarget rather than returned as a valid result. For `FF 15`/`FF 25` forms | ||
| 281 | * the gated value is the pointer slot, which is itself an in-image address. | ||
| 282 | */ | ||
| 283 | [[nodiscard]] std::expected<uintptr_t, RipResolveError> | ||
| 284 | resolve_rip_relative(const std::byte *instruction_address, size_t displacement_offset, | ||
| 285 | size_t instruction_length); | ||
| 286 | |||
| 287 | /** | ||
| 288 | * @brief Scans forward from a starting address for an opcode prefix, then resolves the RIP-relative target. | ||
| 289 | * @details Searches up to search_length bytes for the given opcode prefix. Once found, the displacement is | ||
| 290 | * assumed to immediately follow the prefix. The absolute address is computed as: found_address + | ||
| 291 | * instruction_length + displacement. | ||
| 292 | * @param search_start Pointer to the beginning of the search region. | ||
| 293 | * @param search_length Maximum number of bytes to search forward. | ||
| 294 | * @param opcode_prefix The opcode byte sequence to search for (disp32 must follow immediately). | ||
| 295 | * @param instruction_length Total length of the instruction in bytes. | ||
| 296 | * @return The resolved absolute address, or RipResolveError describing the failure. | ||
| 297 | * @warning For indirect-call / indirect-jump forms (`FF 15 disp32`, `FF 25 disp32`) the returned address is the | ||
| 298 | * *pointer slot* (the address that stores the final target), not the target itself. Dereference it | ||
| 299 | * with `Memory::read_ptr_unsafe` (or an equivalent checked read) to obtain the callee / jump | ||
| 300 | * destination. | ||
| 301 | * @note Matching is first-prefix-wins: the scan resolves the first location whose bytes equal @p opcode_prefix | ||
| 302 | * and does not detect whether the prefix occurs more than once. When a signature may be ambiguous, anchor | ||
| 303 | * it through @ref resolve_cascade (which enforces per-candidate uniqueness) instead. The resolved target | ||
| 304 | * is gated by the same RipResolveError::ImplausibleTarget check as @ref resolve_rip_relative. | ||
| 305 | */ | ||
| 306 | [[nodiscard]] std::expected<uintptr_t, RipResolveError> | ||
| 307 | find_and_resolve_rip_relative(const std::byte *search_start, size_t search_length, | ||
| 308 | std::span<const std::byte> opcode_prefix, size_t instruction_length); | ||
| 309 | |||
| 310 | /** | ||
| 311 | * @brief Scans all committed executable memory regions for a byte pattern. | ||
| 312 | * @details Walks the process address space via VirtualQuery, scanning each committed region with execute | ||
| 313 | * permission. Useful for games with packed or protected binaries that unpack code into anonymous pages | ||
| 314 | * outside any loaded module's address range. | ||
| 315 | * @param pattern The compiled pattern to search for. | ||
| 316 | * @param occurrence Which occurrence to return (1-based). 1 = first match. | ||
| 317 | * @return Pointer to the match (adjusted by pattern offset), or nullptr if not found. | ||
| 318 | * @note Pure-execute pages (`PAGE_EXECUTE` without any read bit) are skipped: | ||
| 319 | * they are not guaranteed readable and dereferencing them raises an access violation. Only | ||
| 320 | * `PAGE_EXECUTE_READ`, `PAGE_EXECUTE_READWRITE`, and `PAGE_EXECUTE_WRITECOPY` regions are inspected. | ||
| 321 | * Guard and no-access pages are skipped unconditionally. | ||
| 322 | * @note `pattern.offset` is applied to the returned pointer, matching `find_pattern`. Callers must not add it | ||
| 323 | * manually. | ||
| 324 | * @warning A trailing `|` marker (offset == pattern.size()) yields a | ||
| 325 | * one-past pointer; bounds-check before dereferencing. | ||
| 326 | * @note A pattern that straddles the boundary between two adjacent accepted regions IS found: the sweep carries | ||
| 327 | * a `pattern_len - 1` byte overlap across the contiguous run of accepted (execute-readable) regions, so a | ||
| 328 | * signature split by a sibling VirtualProtect, or spanning two adjacent execute-readable VAD entries | ||
| 329 | * (JIT-compiled code from Mono / Unreal AngelScript, or heavily unpacked payloads), is still located. The | ||
| 330 | * overlap is capped at `pattern_len - 1` so an interior match is never re-counted. A straddle is missed | ||
| 331 | * only when the regions are not contiguous (a gap between them) or an interior region is unreadable, | ||
| 332 | * which breaks the run. | ||
| 333 | */ | ||
| 334 | [[nodiscard]] const std::byte *scan_executable_regions(const CompiledPattern &pattern, size_t occurrence = 1); | ||
| 335 | |||
| 336 | /** | ||
| 337 | * @brief Scans all committed readable memory regions for a byte pattern. | ||
| 338 | * @details Data-section sibling of scan_executable_regions. Walks the process address space via VirtualQuery | ||
| 339 | * and scans every committed region whose base protection is PAGE_READONLY, PAGE_READWRITE, | ||
| 340 | * PAGE_WRITECOPY, or one of the three execute-readable variants. This reaches .rdata / .data and | ||
| 341 | * read-only heaps: C++ vtables, RTTI type descriptors, localized string pools, and other read-only | ||
| 342 | * metadata that the executable-only sweep cannot see. | ||
| 343 | * @param pattern The compiled pattern to search for. | ||
| 344 | * @param occurrence Which occurrence to return (1-based). 1 = first match. Passing 0 returns nullptr. | ||
| 345 | * @return Pointer to the match (adjusted by pattern offset), or nullptr if not found. | ||
| 346 | * @note The accepted protection set is a strict superset of | ||
| 347 | * scan_executable_regions: execute-readable code pages are included, | ||
| 348 | * so a pattern present in .text is found by both. Callers that specifically want non-code matches must | ||
| 349 | * post-filter (e.g. against | ||
| 350 | * Memory::module_range_for). | ||
| 351 | * @note Guard pages (PAGE_GUARD), no-access pages (PAGE_NOACCESS), and uncommitted regions are skipped: the | ||
| 352 | * first two fault on any touch and are never dereferenced. | ||
| 353 | * @note `pattern.offset` is applied to the returned pointer, matching scan_executable_regions. Callers must not | ||
| 354 | * add it manually. | ||
| 355 | * @note The compiled pattern's own `bytes` buffer is itself readable memory and would otherwise match the | ||
| 356 | * needle against itself. The scan excludes any match overlapping that buffer, so it never returns the | ||
| 357 | * caller's pattern storage. (scan_executable_regions is unaffected because that storage is not | ||
| 358 | * executable.) | ||
| 359 | * @warning The readable address space is far larger than the executable subset (a typical x64 game process maps | ||
| 360 | * hundreds of MB of data versus tens of MB of code) and .rdata pointer tables look random, so a | ||
| 361 | * pattern unique in .text may collide in data. Supply patterns with enough literal bytes (>= 8) to | ||
| 362 | * keep the false-positive rate low. An RTTI mangled-name anchor is fully | ||
| 363 | * ASLR-invariant and far stronger than a raw vtable-header signature, whose relocated pointers vary | ||
| 364 | * per launch. | ||
| 365 | * @warning A trailing `|` marker (offset == pattern.size()) yields a | ||
| 366 | * one-past pointer; bounds-check before dereferencing. | ||
| 367 | * @note A pattern that straddles the boundary between two adjacent accepted regions IS found: the sweep carries | ||
| 368 | * a `pattern_len - 1` byte overlap across the contiguous run of accepted (readable) regions, capped so an | ||
| 369 | * interior match is never re-counted. A straddle is missed only across a gap between regions or an | ||
| 370 | * interior unreadable region that breaks the run. | ||
| 371 | */ | ||
| 372 | [[nodiscard]] const std::byte *scan_readable_regions(const CompiledPattern &pattern, size_t occurrence = 1); | ||
| 373 | |||
| 374 | /** | ||
| 375 | * @enum ScannerKind | ||
| 376 | * @brief Selects which whole-process scanner a cascade resolves against. | ||
| 377 | */ | ||
| 378 | enum class ScannerKind : std::uint8_t | ||
| 379 | { | ||
| 380 | /// scan_executable_regions: committed execute-readable pages. | ||
| 381 | Executable, | ||
| 382 | /// scan_readable_regions: all committed readable pages (superset). | ||
| 383 | Readable | ||
| 384 | }; | ||
| 385 | |||
| 386 | /** | ||
| 387 | * @struct BatchScanItem | ||
| 388 | * @brief One pattern request in a parallel batch scan. | ||
| 389 | * @details A plain-data request: a non-owning pointer to a caller-owned CompiledPattern plus the 1-based | ||
| 390 | * occurrence to resolve. @p pattern must outlive the batch call; a null @p pattern resolves to a | ||
| 391 | * nullptr result slot (fail closed). | ||
| 392 | */ | ||
| 393 | struct BatchScanItem | ||
| 394 | { | ||
| 395 | /// Non-owning pointer to a caller-owned, compiled pattern. Must outlive the batch call. | ||
| 396 | const CompiledPattern *pattern = nullptr; | ||
| 397 | /// Which occurrence to resolve (1-based). 1 = first match. 0 yields a nullptr result. | ||
| 398 | std::size_t occurrence = 1; | ||
| 399 | }; | ||
| 400 | |||
| 401 | /** | ||
| 402 | * @brief Concurrently resolves a batch of compiled patterns against the whole-process region set. | ||
| 403 | * @details Opt-in fork-join sibling of scan_executable_regions / scan_readable_regions. Each item is scanned | ||
| 404 | * independently by exactly one worker through the same per-pattern region walk the serial scanners | ||
| 405 | * use, so a batch of N startup signatures resolves in roughly the time of the slowest single scan | ||
| 406 | * rather than their sum. Results are returned in input order: result[i] is item[i]'s offset-applied | ||
| 407 | * match, or nullptr when the item did not match, its @c pattern is null or empty, or its @c | ||
| 408 | * occurrence is 0. | ||
| 409 | * | ||
| 410 | * Sharing is read-only: a fully compiled CompiledPattern is immutable during scanning (find_pattern | ||
| 411 | * and the region walk take it by const reference and never write back -- an un-anchored pattern | ||
| 412 | * recomputes its anchor into a local, it is not stored), so the workers share the caller's patterns | ||
| 413 | * without cloning. Compile every pattern (parse_aob calls compile_anchor()) before the batch; a | ||
| 414 | * pattern whose anchor is not yet selected still scans correctly but each worker redundantly | ||
| 415 | * recomputes it. | ||
| 416 | * | ||
| 417 | * The driver allocates the result vector up front and hands each worker disjoint result slots through | ||
| 418 | * an atomic work cursor, so there is no result race and no allocation on the scan path. A worker that | ||
| 419 | * throws fails only its own item closed (nullptr); an exception never escapes a worker thread. | ||
| 420 | * @param items The patterns to resolve. An empty span returns an empty vector. | ||
| 421 | * @param kind Which whole-process scanner to use: ScannerKind::Executable (default, execute-readable pages) or | ||
| 422 | * ScannerKind::Readable (all committed readable pages). | ||
| 423 | * @param max_workers Upper bound on worker threads. 0 (default) selects std::thread::hardware_concurrency(), | ||
| 424 | * clamped to the item count. The calling thread participates, so at most @p max_workers | ||
| 425 | * threads scan concurrently. | ||
| 426 | * @return One pointer per input item, in input order (offset-applied match or nullptr). | ||
| 427 | * @note Setup/control-plane only: spawns threads and allocates. Call from init or a worker thread, never from a | ||
| 428 | * hook or input callback, and never under the loader lock (it joins worker threads before returning). | ||
| 429 | */ | ||
| 430 | [[nodiscard]] std::vector<const std::byte *> scan_regions_batch(std::span<const BatchScanItem> items, | ||
| 431 | ScannerKind kind = ScannerKind::Executable, | ||
| 432 | std::size_t max_workers = 0); | ||
| 433 | |||
| 434 | /** | ||
| 435 | * @brief Module-scoped parallel batch scan: confines every item to one mapped image. | ||
| 436 | * @details Fork-join batch sibling of the module-scoped scanners. Each item is resolved only within | ||
| 437 | * [range.base, range.end), reusing the same per-region protection gate and TOCTOU fault guard as the | ||
| 438 | * serial module scans. Threading, result ordering, the immutable-pattern sharing contract, the | ||
| 439 | * per-item fail-closed behaviour, and the @p max_workers semantics match scan_regions_batch. | ||
| 440 | * @param items The patterns to resolve. An empty span returns an empty vector. | ||
| 441 | * @param range The mapped image to scan. An invalid range yields all-nullptr results (every item fails closed). | ||
| 442 | * @param kind ScannerKind::Readable (default) scans every readable page so one pass covers .text and | ||
| 443 | * .rdata / .data candidates; ScannerKind::Executable confines matches to execute-readable code | ||
| 444 | * pages. | ||
| 445 | * @param max_workers Upper bound on worker threads (see scan_regions_batch). 0 selects hardware_concurrency(). | ||
| 446 | * @return One pointer per input item, in input order (offset-applied match or nullptr). | ||
| 447 | * @note Setup/control-plane only, same constraints as scan_regions_batch. | ||
| 448 | */ | ||
| 449 | [[nodiscard]] std::vector<const std::byte *> scan_module_batch(std::span<const BatchScanItem> items, | ||
| 450 | Memory::ModuleRange range, | ||
| 451 | ScannerKind kind = ScannerKind::Readable, | ||
| 452 | std::size_t max_workers = 0); | ||
| 453 | |||
| 454 | /** | ||
| 455 | * @enum StringEncoding | ||
| 456 | * @brief Byte encoding of an anchor string as it is stored in the image. | ||
| 457 | */ | ||
| 458 | enum class StringEncoding : std::uint8_t | ||
| 459 | { | ||
| 460 | /// One byte per character (char / std::string literals). | ||
| 461 | Utf8, | ||
| 462 | /// Two bytes per character, little-endian (wchar_t / L"" on Windows). | ||
| 463 | Utf16le | ||
| 464 | }; | ||
| 465 | |||
| 466 | /** | ||
| 467 | * @enum XrefReturn | ||
| 468 | * @brief What a resolved string cross-reference returns. | ||
| 469 | */ | ||
| 470 | enum class XrefReturn : std::uint8_t | ||
| 471 | { | ||
| 472 | /// Exact address of the instruction that loads the string. | ||
| 473 | ReferencingInstruction, | ||
| 474 | /// Best-effort prologue back-scan from the instruction (heuristic). | ||
| 475 | EnclosingFunction, | ||
| 476 | /** | ||
| 477 | * @brief Address of the global data slot a `mov [rip+slot], reg` stores the loaded string pointer into. | ||
| 478 | * @details Applies when the unique reference is a `lea reg, [rip+string]` shortly followed by that store. | ||
| 479 | * Resolves a cached global string pointer rather than the load site. Reports @ref | ||
| 480 | * StringXrefError::StoreNotFound when no such store follows the reference. | ||
| 481 | */ | ||
| 482 | StringPointerSlot | ||
| 483 | }; | ||
| 484 | |||
| 485 | /** | ||
| 486 | * @enum ResolveMode | ||
| 487 | * @brief How a cascade candidate's pattern maps to a final address. | ||
| 488 | * @details Direct and RipRelative interpret @ref AddrCandidate::pattern as a byte AOB. RttiVtable and | ||
| 489 | * StringXref interpret it as a textual key (an MSVC mangled type name and a literal string | ||
| 490 | * respectively) and resolve through the name/string backends instead of a byte scan. | ||
| 491 | */ | ||
| 492 | enum class ResolveMode : std::uint8_t | ||
| 493 | { | ||
| 494 | /// Returned address = match + disp_offset. | ||
| 495 | Direct, | ||
| 496 | /// Read int32 displacement at (match + disp_offset), compute match + instr_end_offset + disp. | ||
| 497 | RipRelative, | ||
| 498 | /** | ||
| 499 | * @brief @ref AddrCandidate::pattern is an MSVC mangled type name; resolve via Rtti::vtable_for_type. | ||
| 500 | * @details Returns the type's primary (COL.offset == 0) vtable. Scoped to the cascade's explicit module | ||
| 501 | * range, or Memory::host_module_range() when the resolver carries none (the range-less | ||
| 502 | * whole-process cascades), because a COL's RVAs are image-base-relative. Always unique-only: the | ||
| 503 | * backend fails closed on an ambiguous name, so @ref AddrCandidate::require_unique does not apply. | ||
| 504 | */ | ||
| 505 | RttiVtable, | ||
| 506 | /** | ||
| 507 | * @brief @ref AddrCandidate::pattern is a literal string; resolve via find_string_xref. | ||
| 508 | * @details Anchors on the immutable literal, then its unique RIP-relative reference. The @c xref_* facet | ||
| 509 | * fields tune the query. Scoped to the cascade's explicit module range, or | ||
| 510 | * Memory::host_module_range() when the resolver carries none, because an in-image string xref is | ||
| 511 | * image-scoped. Always unique-only: the backend fails closed on a pooled string or an ambiguous | ||
| 512 | * reference, so @ref AddrCandidate::require_unique does not apply. | ||
| 513 | */ | ||
| 514 | StringXref | ||
| 515 | }; | ||
| 516 | |||
| 517 | /** | ||
| 518 | * @struct AddrCandidate | ||
| 519 | * @brief One ordered attempt in a cascade. | ||
| 520 | * @details The cascade scans candidates in array order and returns the first successful resolution. @p name is | ||
| 521 | * echoed back in the | ||
| 522 | * ResolveHit on success so callers can log which candidate won -- useful when multiple patterns cover | ||
| 523 | * different game versions. @p pattern is a byte AOB for ResolveMode::Direct / RipRelative, an MSVC | ||
| 524 | * mangled type name for ResolveMode::RttiVtable, and a literal string for ResolveMode::StringXref. The | ||
| 525 | * four @c xref_* facet fields apply only to ResolveMode::StringXref and are ignored otherwise; they | ||
| 526 | * mirror the StringRefQuery defaults so a plain StringXref row needs only @p pattern. | ||
| 527 | */ | ||
| 528 | struct AddrCandidate | ||
| 529 | { | ||
| 530 | std::string_view name; | ||
| 531 | std::string_view pattern; | ||
| 532 | ResolveMode mode = ResolveMode::Direct; | ||
| 533 | std::ptrdiff_t disp_offset = 0; | ||
| 534 | std::ptrdiff_t instr_end_offset = 0; | ||
| 535 | |||
| 536 | /** | ||
| 537 | * @brief Require the candidate to match exactly once in the scanned scope; defaults to true. A second match | ||
| 538 | * makes the candidate ambiguous and it is skipped. | ||
| 539 | * @details A cascade returns the first candidate that resolves, and a single scan returns the | ||
| 540 | * lowest-address match. A loose pattern that matches several functions would therefore win on | ||
| 541 | * whichever address sorts first -- usually not the intended one, and impossible to recover from | ||
| 542 | * after the fact (the resolver has already committed). Defaulting this to true makes the resolver | ||
| 543 | * count the candidate's matches within the scanned scope (the module image for the @c *_in_module | ||
| 544 | * resolvers, the whole process otherwise); if a second match exists the candidate falls through to | ||
| 545 | * the next one. That converts a silent wrong resolution into a clean fall-through, and -- when no | ||
| 546 | * candidate is provably unique -- a NoMatch the caller can act on (a signal that the target binary | ||
| 547 | * changed enough to need new signatures) rather than a confidently wrong hit. | ||
| 548 | * | ||
| 549 | * Set this to false for a candidate that is deliberately non-unique and whose first match is the | ||
| 550 | * intended one (e.g. "first occurrence of a common instruction", or a last-resort broad net). The | ||
| 551 | * flag is per-candidate, so a strict primary anchor keeps the default while a broad fallback opts | ||
| 552 | * out. The uniqueness scan runs once per candidate that already matched; opt out to skip it. | ||
| 553 | * | ||
| 554 | * Has no effect for ResolveMode::RttiVtable / StringXref: those backends are unique-only and | ||
| 555 | * fail closed on ambiguity, so a non-unique name or pooled string falls through regardless of | ||
| 556 | * this flag. | ||
| 557 | */ | ||
| 558 | bool require_unique = true; | ||
| 559 | |||
| 560 | /// StringXref only: byte encoding of the literal as stored in the image. Ignored for other modes. | ||
| 561 | StringEncoding xref_encoding = StringEncoding::Utf8; | ||
| 562 | /// StringXref only: what a resolved reference returns (load site, enclosing function, or pointer slot). | ||
| 563 | XrefReturn xref_return = XrefReturn::ReferencingInstruction; | ||
| 564 | /// StringXref only: match a trailing NUL so a prefix of a longer literal is not matched. | ||
| 565 | bool xref_require_terminator = true; | ||
| 566 | /// StringXref only: also run the Zydis broad reference sweep for rarer RIP-relative shapes. | ||
| 567 | bool xref_broad_match = false; | ||
| 568 | }; | ||
| 569 | |||
| 570 | /** | ||
| 571 | * @enum ResolveError | ||
| 572 | * @brief Reasons a cascade resolve may fail. | ||
| 573 | */ | ||
| 574 | enum class ResolveError : std::uint8_t | ||
| 575 | { | ||
| 576 | EmptyCandidates, | ||
| 577 | NoMatch, | ||
| 578 | AllPatternsInvalid, | ||
| 579 | PrologueFallbackNotApplicable, | ||
| 580 | InvalidRange, | ||
| 581 | DecodeFailed, | ||
| 582 | UnexpectedShape, | ||
| 583 | OperandOutOfRange | ||
| 584 | }; | ||
| 585 | |||
| 586 | /** | ||
| 587 | * @brief Human-readable mapping for ResolveError. | ||
| 588 | */ | ||
| 589 | 8 | [[nodiscard]] constexpr std::string_view resolve_error_to_string(ResolveError error) noexcept | |
| 590 | { | ||
| 591 |
8/9✓ 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.
✓ Branch 2 → 9 taken 1 time.
✓ Branch 2 → 10 taken 1 time.
✗ Branch 2 → 11 not taken.
|
8 | switch (error) |
| 592 | { | ||
| 593 | 1 | case ResolveError::EmptyCandidates: | |
| 594 | 1 | return "No candidates supplied"; | |
| 595 | 1 | case ResolveError::NoMatch: | |
| 596 | 1 | return "No cascade candidate matched the scanned scope"; | |
| 597 | 1 | case ResolveError::AllPatternsInvalid: | |
| 598 | 1 | return "Every byte candidate pattern failed to parse"; | |
| 599 | 1 | case ResolveError::PrologueFallbackNotApplicable: | |
| 600 | 1 | return "Prologue fallback pattern too short to be unique"; | |
| 601 | 1 | case ResolveError::InvalidRange: | |
| 602 | 1 | return "Supplied module range is invalid"; | |
| 603 | 1 | case ResolveError::DecodeFailed: | |
| 604 | 1 | return "Instruction at the resolved site did not decode"; | |
| 605 | 1 | case ResolveError::UnexpectedShape: | |
| 606 | 1 | return "Decoded operand is not the requested kind"; | |
| 607 | 1 | case ResolveError::OperandOutOfRange: | |
| 608 | 1 | return "Operand index exceeds the instruction operand count"; | |
| 609 | ✗ | default: | |
| 610 | ✗ | return "Unknown resolve error"; | |
| 611 | } | ||
| 612 | } | ||
| 613 | |||
| 614 | /** | ||
| 615 | * @struct ResolveHit | ||
| 616 | * @brief Successful cascade outcome. | ||
| 617 | * @details @p winning_name aliases the matching candidate's @c name field. The underlying storage must outlive | ||
| 618 | * the ResolveHit (AddrCandidate arrays typically live in static storage). | ||
| 619 | */ | ||
| 620 | struct ResolveHit | ||
| 621 | { | ||
| 622 | std::uintptr_t address{0}; | ||
| 623 | std::string_view winning_name; | ||
| 624 | }; | ||
| 625 | |||
| 626 | /** | ||
| 627 | * @struct CascadeRequest | ||
| 628 | * @brief One cascade resolver request in a parallel batch. | ||
| 629 | * @details A plain-data request over caller-owned storage: @ref candidates and @ref label must outlive the | ||
| 630 | * batch call, and any successful @ref ResolveHit::winning_name aliases the winning candidate's @ref | ||
| 631 | * AddrCandidate::name. When @ref range is set the request uses the module-scoped cascade; when it is | ||
| 632 | * empty the whole-process cascade is used. @ref prologue_fallback selects the matching | ||
| 633 | * *_with_prologue_fallback resolver. @ref kind applies only to whole-process non-fallback requests; | ||
| 634 | * module-scoped and prologue-fallback requests keep their existing serial resolver semantics. | ||
| 635 | */ | ||
| 636 | struct CascadeRequest | ||
| 637 | { | ||
| 638 | /// Ordered candidates for one target. | ||
| 639 | std::span<const AddrCandidate> candidates; | ||
| 640 | /// Human-readable identifier used in log messages. | ||
| 641 | std::string_view label; | ||
| 642 | /// Optional module image scope; std::nullopt selects the whole-process resolver. | ||
| 643 | std::optional<Memory::ModuleRange> range = std::nullopt; | ||
| 644 | /// Whole-process scanner kind for non-fallback requests. | ||
| 645 | ScannerKind kind = ScannerKind::Executable; | ||
| 646 | /// Enable the hooked-prologue recovery variant for this request. | ||
| 647 | bool prologue_fallback = false; | ||
| 648 | }; | ||
| 649 | |||
| 650 | /** | ||
| 651 | * @brief Resolves independent cascade requests concurrently. | ||
| 652 | * @details Fork-join resolver layer over resolve_cascade(), resolve_cascade_in_module(), and their | ||
| 653 | * prologue-fallback variants. Each request is resolved by exactly one worker through the existing | ||
| 654 | * serial resolver, preserving candidate order, first-success semantics, uniqueness checks, typed | ||
| 655 | * errors, and @ref ResolveHit::winning_name aliasing. Results are returned in input order. | ||
| 656 | * @param requests Cascade requests to resolve. An empty span returns an empty vector. | ||
| 657 | * @param max_workers Upper bound on concurrent workers. 0 selects std::thread::hardware_concurrency(), clamped | ||
| 658 | * to the request count. The calling thread participates. | ||
| 659 | * @return One expected result per input request, in input order. | ||
| 660 | * @note Setup/control-plane only: spawns threads and allocates. Call from init or a worker thread, never from a | ||
| 661 | * hook or input callback, and never under the loader lock. | ||
| 662 | */ | ||
| 663 | [[nodiscard]] std::vector<std::expected<ResolveHit, ResolveError>> | ||
| 664 | resolve_cascade_batch(std::span<const CascadeRequest> requests, std::size_t max_workers = 0); | ||
| 665 | |||
| 666 | /** | ||
| 667 | * @brief Try candidates in order; return the first successful address. | ||
| 668 | * @details Each candidate's pattern is compiled via parse_aob() and searched via the scanner selected by @p | ||
| 669 | * kind: | ||
| 670 | * scan_executable_regions() for ScannerKind::Executable (the default) or scan_readable_regions() for | ||
| 671 | * ScannerKind::Readable when the target lives in .rdata / .data. Direct mode returns @c match + | ||
| 672 | * disp_offset. RipRelative mode treats @c match + disp_offset as a disp32 field and resolves against | ||
| 673 | * @c match + instr_end_offset. On success, the winning candidate's name is logged and returned. | ||
| 674 | * | ||
| 675 | * Logging: | ||
| 676 | * - Debug on first success: "<label> resolved via '<name>' at 0x...". | ||
| 677 | * - Warning per candidate whose pattern fails to parse. | ||
| 678 | * - Warning on total failure. | ||
| 679 | * | ||
| 680 | * The success line is Debug-level, consistent with the other resolution diagnostics, so it stays | ||
| 681 | * silent at the default | ||
| 682 | * Info threshold; raise the log level to Debug to surface it for build identification. No | ||
| 683 | * per-candidate "miss" line is produced, so even a long cascade stays quiet at Info and above. The | ||
| 684 | * implementation does not log again when resolve_cascade_with_prologue_fallback() retries, so exactly | ||
| 685 | * one success line is emitted per resolve. | ||
| 686 | * | ||
| 687 | * @param candidates Ordered list of candidates. Empty -> EmptyCandidates. | ||
| 688 | * @param label Human-readable identifier used in log messages. | ||
| 689 | * @param kind Which scanner to search with. Defaults to | ||
| 690 | * ScannerKind::Executable so existing call sites are unchanged; pass ScannerKind::Readable for | ||
| 691 | * data-section targets. | ||
| 692 | * @return ResolveHit on success; ResolveError on failure. | ||
| 693 | */ | ||
| 694 | [[nodiscard]] std::expected<ResolveHit, ResolveError> | ||
| 695 | resolve_cascade(std::span<const AddrCandidate> candidates, std::string_view label, | ||
| 696 | ScannerKind kind = ScannerKind::Executable); | ||
| 697 | |||
| 698 | /** | ||
| 699 | * @brief Cascade resolver with inline-hooked-prologue recovery. | ||
| 700 | * @details Equivalent to resolve_cascade() on the happy path. If every candidate fails, rebuilds each | ||
| 701 | * Direct-mode candidate's pattern with the patched prologue replaced by a jump shape and retries. Four | ||
| 702 | * shapes are tried in order: the 5-byte `E9 ?? ?? ?? ??` near jump (SafetyHook and other rel32 inline | ||
| 703 | * detours); the 6-byte `FF 25 ?? ?? ?? ??` RIP-relative indirect jump a detour emits when its | ||
| 704 | * trampoline is beyond rel32 reach with the absolute target in a separate slot (a Detours-style far | ||
| 705 | * jump); the 14-byte `FF 25 00 00 00 00 <abs64>` absolute form whose disp32 is zero so the 8-byte | ||
| 706 | * target is inlined right after the instruction; and the 12-byte `mov rax, imm64; jmp rax` | ||
| 707 | * (`48 B8 <imm64> FF E0`) absolute jump some libraries emit instead. The recovered jump destination is | ||
| 708 | * gated as a plausible, executable address before acceptance. If the recovery path succeeds the log | ||
| 709 | * line calls this out explicitly. | ||
| 710 | * | ||
| 711 | * RipRelative candidates are skipped in the fallback phase since they target instructions deeper than | ||
| 712 | * the patched prologue and are unaffected by the overwrite. | ||
| 713 | * | ||
| 714 | * @note Recovery covers the E9 near-jump and the three far-jump shapes above, and never returns a | ||
| 715 | * wrong address. Two failure modes are distinct and worth handling separately: NoMatch means the | ||
| 716 | * direct scan and every rebuilt fallback shape both ran and matched nothing (the case for a | ||
| 717 | * prologue overwritten by an unhandled shape such as a push imm32 / ret thunk, an FF15 call thunk, | ||
| 718 | * or a prefixed jump); PrologueFallbackNotApplicable means a Direct-mode candidate was present to | ||
| 719 | * rebuild but its literal tail was too short to form a unique pattern around the prologue, so nothing | ||
| 720 | * was retried. A cascade with no Direct-mode candidate at all (only RttiVtable / StringXref / RipRelative | ||
| 721 | * tiers) has nothing to rebuild, so a full miss there is NoMatch, not PrologueFallbackNotApplicable. Do | ||
| 722 | * not assume every unsupported overwrite collapses to NoMatch. | ||
| 723 | * @param candidates Ordered candidates. | ||
| 724 | * @param label Human-readable identifier used in log messages. | ||
| 725 | * @return ResolveHit on success; ResolveError on failure. | ||
| 726 | */ | ||
| 727 | [[nodiscard]] std::expected<ResolveHit, ResolveError> | ||
| 728 | resolve_cascade_with_prologue_fallback(std::span<const AddrCandidate> candidates, std::string_view label); | ||
| 729 | |||
| 730 | /** | ||
| 731 | * @brief Module-scoped cascade: like resolve_cascade(), but searches only the mapped image [range.base, | ||
| 732 | * range.end) and rejects any resolution that lands outside it. | ||
| 733 | * @details A whole-process scan (resolve_cascade) returns the first candidate that matches anywhere in the | ||
| 734 | * address space. For an unpacked PE whose every hook target lives inside one module that is unsafe: a | ||
| 735 | * generic-shaped candidate (a stock compiler prologue, a `mov reg,[rip]; ...; ret` epilogue) can | ||
| 736 | * false-match inside another injected module (a graphics overlay, a sibling mod). Because the cascade | ||
| 737 | * is first-match-wins, the wrong match is returned and shadows the correct in-module one; a caller's | ||
| 738 | * post-resolution bounds check cannot undo it, since the cascade has already committed to the | ||
| 739 | * colliding candidate. | ||
| 740 | * | ||
| 741 | * This overload moves the scope and bounds decision inside the cascade loop. A candidate wins only | ||
| 742 | * when it (1) parses, (2) matches via a scan confined to [range.base, range.end), and (3) resolves | ||
| 743 | * (Direct walk or RipRelative disp read) to an address for which Memory::contains(range, addr) is | ||
| 744 | * true. Any failure at any step falls through to the next candidate, so a | ||
| 745 | * P1 that resolves out of module yields to the in-module P2/P3. | ||
| 746 | * | ||
| 747 | * One scan of the contiguous image covers both .text and | ||
| 748 | * .rdata / .data candidates, so there is no ScannerKind | ||
| 749 | * parameter: the section split that ScannerKind selects for | ||
| 750 | * whole-process sweeps is moot inside a single mapped PE. The scan reuses the same per-region | ||
| 751 | * protection filter as the whole-process scanners, so a non-readable interior page (a | ||
| 752 | * section-alignment gap, a guard page, a sibling VirtualProtect) is skipped rather than dereferenced. | ||
| 753 | * | ||
| 754 | * @param candidates Ordered list of candidates. Empty -> EmptyCandidates. | ||
| 755 | * @param label Human-readable identifier used in log messages. | ||
| 756 | * @param range The mapped image to scan, e.g. from | ||
| 757 | * Memory::module_range_for(), Memory::own_module_range(), or an explicit {base, base + | ||
| 758 | * SizeOfImage}. | ||
| 759 | * @return ResolveHit on success; ResolveError on failure. An invalid @p range returns | ||
| 760 | * ResolveError::InvalidRange and never falls back to a whole-process scan. | ||
| 761 | * @note Memory::contains gates reachability, not section identity: a | ||
| 762 | * RipRelative candidate resolving into .rdata / .data inside the image is accepted. Direct candidates | ||
| 763 | * that must land on code should still be paired with is_likely_function_prologue(). | ||
| 764 | * @pre @p range must describe a single contiguous mapped image. Do not use this overload for packed or | ||
| 765 | * protected targets whose code is unpacked into separate VirtualAlloc regions outside the module image; | ||
| 766 | * use resolve_cascade() for those. | ||
| 767 | */ | ||
| 768 | [[nodiscard]] std::expected<ResolveHit, ResolveError> | ||
| 769 | resolve_cascade_in_module(std::span<const AddrCandidate> candidates, std::string_view label, | ||
| 770 | Memory::ModuleRange range); | ||
| 771 | |||
| 772 | /** | ||
| 773 | * @brief Module-scoped variant of resolve_cascade_with_prologue_fallback(). | ||
| 774 | * @details Equivalent to resolve_cascade_in_module() on the happy path. If every candidate fails, it | ||
| 775 | * rebuilds each Direct-mode candidate's prologue as each recognised inline-hook jump shape (the | ||
| 776 | * same four shapes resolve_cascade_with_prologue_fallback() tries: `E9` near jump, `FF 25` | ||
| 777 | * indirect, `FF 25 00 00 00 00 <abs64>` absolute, and `mov rax, imm64; jmp rax`) plus the | ||
| 778 | * original literal tail and retries, confining both the uniqueness count and the match to | ||
| 779 | * [range.base, range.end). The fallback scan is restricted to the image's executable pages: a | ||
| 780 | * hooked jump overwrites a code prologue, never data, so a match in .rdata / .data would be a | ||
| 781 | * false positive (the data-capable readable sweep is only used for the primary candidate pass). | ||
| 782 | * | ||
| 783 | * The rebuilt jump must be found inside @p range, but its jump destination is intentionally not | ||
| 784 | * constrained to @p range or to any loaded module. When a sibling mod inline-hooks the target, | ||
| 785 | * its jump usually targets a VirtualAlloc'd trampoline outside every image, so the destination | ||
| 786 | * is validated as a plausible pointer on a committed, execute-readable page instead. This still | ||
| 787 | * rejects jumps into unmapped or data-only memory without rejecting the recovery this path | ||
| 788 | * exists to perform. | ||
| 789 | * | ||
| 790 | * @param candidates Ordered candidates. | ||
| 791 | * @param label Human-readable identifier used in log messages. | ||
| 792 | * @param range The mapped image to scan. | ||
| 793 | * @return ResolveHit on success; ResolveError on failure. An invalid @p range returns | ||
| 794 | * ResolveError::InvalidRange. | ||
| 795 | */ | ||
| 796 | [[nodiscard]] std::expected<ResolveHit, ResolveError> | ||
| 797 | resolve_cascade_in_module_with_prologue_fallback(std::span<const AddrCandidate> candidates, | ||
| 798 | std::string_view label, Memory::ModuleRange range); | ||
| 799 | |||
| 800 | /** | ||
| 801 | * @brief Convenience: resolve_cascade_in_module() scoped to the host EXE. | ||
| 802 | * @details Forwards to resolve_cascade_in_module() with the range of the process's main executable image | ||
| 803 | * (Memory::host_module_range()). This is the overwhelmingly common scope for an injected ASI whose | ||
| 804 | * target code lives in the game's own EXE, and it removes the boilerplate of building the range at | ||
| 805 | * every call site. | ||
| 806 | * @warning Use this ONLY when the host executable is the image that holds the target code. For a game whose | ||
| 807 | * logic lives in a separate module (for example an engine DLL loaded by a thin launcher | ||
| 808 | * EXE), resolve that module's range explicitly and call resolve_cascade_in_module(): the host EXE then | ||
| 809 | * holds none of the target code, so host-scoping would scan the wrong image. | ||
| 810 | * @param candidates Ordered candidates. | ||
| 811 | * @param label Human-readable identifier used in log messages. | ||
| 812 | * @return ResolveHit on success; ResolveError on failure. If the host module range cannot be determined the | ||
| 813 | * result is | ||
| 814 | * ResolveError::InvalidRange. | ||
| 815 | */ | ||
| 816 | [[nodiscard]] std::expected<ResolveHit, ResolveError> | ||
| 817 | resolve_cascade_in_host_module(std::span<const AddrCandidate> candidates, std::string_view label); | ||
| 818 | |||
| 819 | /** | ||
| 820 | * @brief Host-EXE-scoped variant of resolve_cascade_in_module_with_prologue_fallback(). | ||
| 821 | * @details Forwards to resolve_cascade_in_module_with_prologue_fallback() with Memory::host_module_range(). | ||
| 822 | * Same host-scope caveat as resolve_cascade_in_host_module() applies. | ||
| 823 | * @param candidates Ordered candidates. | ||
| 824 | * @param label Human-readable identifier used in log messages. | ||
| 825 | * @return ResolveHit on success; ResolveError on failure. If the host module range cannot be determined the | ||
| 826 | * result is | ||
| 827 | * ResolveError::InvalidRange. | ||
| 828 | */ | ||
| 829 | [[nodiscard]] std::expected<ResolveHit, ResolveError> | ||
| 830 | resolve_cascade_in_host_module_with_prologue_fallback(std::span<const AddrCandidate> candidates, | ||
| 831 | std::string_view label); | ||
| 832 | |||
| 833 | /** | ||
| 834 | * @enum OperandKind | ||
| 835 | * @brief Which operand field @ref read_code_constant extracts. | ||
| 836 | */ | ||
| 837 | enum class OperandKind : std::uint8_t | ||
| 838 | { | ||
| 839 | /// An immediate operand (e.g. the imm of `add reg, imm`). | ||
| 840 | Immediate, | ||
| 841 | /// A memory operand's displacement (e.g. the disp of `[reg + disp]`). | ||
| 842 | MemoryDisplacement | ||
| 843 | }; | ||
| 844 | |||
| 845 | /** | ||
| 846 | * @struct CodeConstant | ||
| 847 | * @brief Declares a constant encoded in the engine's machine code so DMK can re-derive it after a patch instead | ||
| 848 | * of hard-coding it. | ||
| 849 | * @details The code-side twin of the RTTI self-heal: where a struct stride or field displacement is an | ||
| 850 | * immediate or `[reg + disp]` in a dispatch loop, declare the AOB-resolved instruction site plus which | ||
| 851 | * operand to read, and @ref read_code_constant decodes the live instruction and returns the current | ||
| 852 | * value. A consumer stops hand-reading the immediate every patch. | ||
| 853 | */ | ||
| 854 | struct CodeConstant | ||
| 855 | { | ||
| 856 | /// AOB cascade that lands ON the instruction (a Direct candidate, disp_offset 0). | ||
| 857 | std::span<const AddrCandidate> site; | ||
| 858 | /// Which operand field to read: an immediate or a memory displacement. | ||
| 859 | OperandKind kind = OperandKind::Immediate; | ||
| 860 | /// Index into the instruction's VISIBLE operands, as counted in a disassembler. | ||
| 861 | std::uint8_t operand_index = 0; | ||
| 862 | /// 0 returns Zydis's already-sign-extended value; > 0 narrows to this many bytes then re-sign-extends. | ||
| 863 | std::uint8_t byte_width = 0; | ||
| 864 | /// Last-known value, for telemetry/baseline ONLY; never returned in place of a live decode. | ||
| 865 | std::int64_t nominal = 0; | ||
| 866 | /// Set true to make @ref nominal meaningful (do not overload nominal == 0 as "unset"). | ||
| 867 | bool has_nominal = false; | ||
| 868 | }; | ||
| 869 | |||
| 870 | /** | ||
| 871 | * @brief Resolves @p cc.site, decodes the instruction there, and returns the requested operand's current value. | ||
| 872 | * @details Always decodes and returns the live operand (sign-extended); | ||
| 873 | * @c cc.nominal is never a short-circuit, so a same-shape / different-value drift (e.g. a stride 232 | ||
| 874 | * -> 240) is reported as the new value, which is the whole point. Self-validating and | ||
| 875 | * fail-closed: a site that no longer decodes, or whose requested | ||
| 876 | * operand is the wrong kind or out of range, returns a typed error rather than a guess. A RIP-relative | ||
| 877 | * memory operand is resolved to its absolute target (so the return is an absolute address in that | ||
| 878 | * case); other relative forms are reported as a value as-is. | ||
| 879 | * @param cc The code-constant declaration. | ||
| 880 | * @param range Module image to resolve the site in. Defaults to the host EXE. | ||
| 881 | * @return The decoded value, or: | ||
| 882 | * - any @ref ResolveError from resolving @c cc.site (EmptyCandidates, | ||
| 883 | * NoMatch, InvalidRange, ...); | ||
| 884 | * - @ref ResolveError::DecodeFailed if the site does not decode; | ||
| 885 | * - @ref ResolveError::OperandOutOfRange if @c operand_index is past | ||
| 886 | * the visible operand count; | ||
| 887 | * - @ref ResolveError::UnexpectedShape if the operand is not the | ||
| 888 | * requested @c kind (or a memory operand carries no displacement). | ||
| 889 | */ | ||
| 890 | [[nodiscard]] std::expected<std::int64_t, ResolveError> | ||
| 891 | read_code_constant(const CodeConstant &cc, Memory::ModuleRange range = Memory::host_module_range()); | ||
| 892 | |||
| 893 | /** | ||
| 894 | * @enum StringXrefError | ||
| 895 | * @brief Typed failure of @ref find_string_xref. Fail-closed, like @ref RipResolveError. | ||
| 896 | */ | ||
| 897 | enum class StringXrefError : std::uint8_t | ||
| 898 | { | ||
| 899 | /// The query text was empty. | ||
| 900 | EmptyQuery, | ||
| 901 | /// @p range was not a valid mapped image. | ||
| 902 | InvalidRange, | ||
| 903 | /// The string bytes were not found in any readable page of the image. | ||
| 904 | StringNotFound, | ||
| 905 | /// The string occurs more than once (linker-pooled or repeated). | ||
| 906 | StringAmbiguous, | ||
| 907 | /// No recognized RIP-relative reference in the image resolves to the string. | ||
| 908 | NoReference, | ||
| 909 | /// More than one instruction references the string. | ||
| 910 | AmbiguousReference, | ||
| 911 | /// EnclosingFunction mode: no prologue within the back-scan window. | ||
| 912 | FunctionNotFound, | ||
| 913 | /// StringPointerSlot mode: no `mov [rip+slot], reg` store of the loaded pointer follows the reference. | ||
| 914 | StoreNotFound | ||
| 915 | }; | ||
| 916 | |||
| 917 | /** | ||
| 918 | * @brief Converts a StringXrefError to a human-readable string. | ||
| 919 | * @param error The error code. | ||
| 920 | * @return A string view describing the error. | ||
| 921 | */ | ||
| 922 | 8 | [[nodiscard]] constexpr std::string_view string_xref_error_to_string(StringXrefError error) noexcept | |
| 923 | { | ||
| 924 |
8/9✓ 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.
✓ Branch 2 → 9 taken 1 time.
✓ Branch 2 → 10 taken 1 time.
✗ Branch 2 → 11 not taken.
|
8 | switch (error) |
| 925 | { | ||
| 926 | 1 | case StringXrefError::EmptyQuery: | |
| 927 | 1 | return "Query text was empty"; | |
| 928 | 1 | case StringXrefError::InvalidRange: | |
| 929 | 1 | return "Module range is not a valid mapped image"; | |
| 930 | 1 | case StringXrefError::StringNotFound: | |
| 931 | 1 | return "String bytes not found in the image"; | |
| 932 | 1 | case StringXrefError::StringAmbiguous: | |
| 933 | 1 | return "String occurs more than once in the image"; | |
| 934 | 1 | case StringXrefError::NoReference: | |
| 935 | 1 | return "No instruction references the string"; | |
| 936 | 1 | case StringXrefError::AmbiguousReference: | |
| 937 | 1 | return "More than one instruction references the string"; | |
| 938 | 1 | case StringXrefError::FunctionNotFound: | |
| 939 | 1 | return "No enclosing function prologue found in the back-scan window"; | |
| 940 | 1 | case StringXrefError::StoreNotFound: | |
| 941 | 1 | return "No store of the loaded string pointer into a global slot follows the reference"; | |
| 942 | ✗ | default: | |
| 943 | ✗ | return "Unknown string xref error"; | |
| 944 | } | ||
| 945 | } | ||
| 946 | |||
| 947 | /** | ||
| 948 | * @struct StringRefQuery | ||
| 949 | * @brief A string-reference anchor query. | ||
| 950 | * @details Anchors a target on an immutable string literal in the image's read-only data, then resolves the | ||
| 951 | * unique RIP-relative reference to it. Strings survive game updates far better than the code bytes | ||
| 952 | * around them, so a string xref is the most update-resilient anchor source. @ref text is a non-owning | ||
| 953 | * view into caller storage (a static table), matching the @ref AddrCandidate / @ref Rtti::Landmark | ||
| 954 | * style. | ||
| 955 | */ | ||
| 956 | struct StringRefQuery | ||
| 957 | { | ||
| 958 | /// Literal content (no quotes). | ||
| 959 | std::string_view text; | ||
| 960 | /// How it is stored in the image. | ||
| 961 | StringEncoding encoding = StringEncoding::Utf8; | ||
| 962 | /** | ||
| 963 | * @brief Match a trailing NUL so a prefix of a longer literal is not matched (e.g. "Player" inside | ||
| 964 | * "PlayerController"). | ||
| 965 | */ | ||
| 966 | bool require_terminator = true; | ||
| 967 | /// Selects the exact instruction site, the enclosing function heuristic, or the cached global pointer slot. | ||
| 968 | XrefReturn return_mode = XrefReturn::ReferencingInstruction; | ||
| 969 | /** | ||
| 970 | * @brief Selects the phase-2 reference scan. | ||
| 971 | * @details false (default) runs the fast, desync-immune all-offset shape scan that recognizes only the | ||
| 972 | * REX.W `lea`/`mov reg, [rip+disp32]` forms. true keeps that scan and also runs a Zydis-verified | ||
| 973 | * linear sweep that recognizes the rarer RIP-relative reference shapes (`cmp [rip+d], imm`, `push | ||
| 974 | * [rip+d]`, a no-REX `lea`/`mov`, ...), at the cost of a full decode per instruction. Both scans | ||
| 975 | * apply the same exact-target and single-reference uniqueness guards, so broad mode adds coverage | ||
| 976 | * without relaxing fail-closed behaviour. | ||
| 977 | */ | ||
| 978 | bool broad_match = false; | ||
| 979 | }; | ||
| 980 | |||
| 981 | /** | ||
| 982 | * @brief Resolves a string-reference anchor inside one mapped image. | ||
| 983 | * @details Two fail-closed phases. Phase 1 locates the single occurrence of @p query.text in the image's | ||
| 984 | * readable pages (zero -> | ||
| 985 | * StringNotFound, more than one -> StringAmbiguous; the linker pools identical literals, so a | ||
| 986 | * non-unique string is genuinely ambiguous). Phase 2 scans the image's execute-readable pages for the | ||
| 987 | * single RIP-relative reference whose resolved absolute target is that string (zero -> NoReference, | ||
| 988 | * more than one -> | ||
| 989 | * AmbiguousReference). A reference counts only when its resolved target exactly equals the located | ||
| 990 | * string address, which is itself a plausible in-image pointer, so the equality subsumes the @ref | ||
| 991 | * Memory::plausible_userspace_ptr floor that @ref resolve_rip_relative applies, without a separate | ||
| 992 | * check. The xref is RIP-relative, so the result is ASLR-correct by construction (no fixed address is | ||
| 993 | * baked in). | ||
| 994 | * @param query The string and how to interpret the reference. | ||
| 995 | * @param range Module image to search. Defaults to the host EXE. | ||
| 996 | * @return The referencing instruction (or enclosing function) address, or a StringXrefError. | ||
| 997 | * @note By default phase 2 recognizes the dominant 64-bit string-load forms: REX.W `lea`/`mov reg, | ||
| 998 | * [rip+disp32]` (opcodes 8D / 8B with a RIP ModRM). Set @ref StringRefQuery::broad_match to keep that | ||
| 999 | * all-offset shape scan and additionally recognize the rarer RIP-relative shapes (`cmp [rip+d], imm`, | ||
| 1000 | * `push [rip+d]`, a no-REX `lea`/`mov`) via a Zydis-verified sweep. Either way, a shape the active scans | ||
| 1001 | * do not model reports NoReference rather than a guess. | ||
| 1002 | * @note Phase 2 scans each execute-readable window from the image independently and, unlike @ref | ||
| 1003 | * find_pattern, carries no cross-window overlap. A RIP-relative reference whose bytes span the | ||
| 1004 | * boundary between two separate executable windows (a protection split inside .text, say) is decoded | ||
| 1005 | * in neither and reports NoReference: a fail-closed miss, never a wrong match. The common case, one | ||
| 1006 | * contiguous executable section, has no interior boundary. | ||
| 1007 | * @note XrefReturn::StringPointerSlot requires the unique reference to be a REX.W `lea reg, [rip+string]` and | ||
| 1008 | * returns the effective address of the global slot the first `mov [rip+slot], reg` (same source register) | ||
| 1009 | * within a bounded forward window stores the loaded pointer into. It resolves a cached global string | ||
| 1010 | * pointer rather than the load site. A `mov reg, [rip+string]` load, a broad-only reference, or no | ||
| 1011 | * matching store reports StoreNotFound. The store match is first-within-window (compilers emit it | ||
| 1012 | * next to the load) and is not uniqueness-checked; the forward scan bails to StoreNotFound on a CALL, | ||
| 1013 | * on any write to the loaded register (at any width), or on a decode failure, so a clobbered or | ||
| 1014 | * post-call store is never misattributed. | ||
| 1015 | * @note Choose a string referenced exactly once (a long, specific literal such as a format or assert message); | ||
| 1016 | * short, common strings are pooled and shared and will report StringAmbiguous / AmbiguousReference. | ||
| 1017 | * @note StringEncoding::Utf16le widens each query byte to a little-endian 16-bit code unit (the byte | ||
| 1018 | * followed by 0x00), treating query bytes as Latin-1 code units; this covers the ASCII identifiers | ||
| 1019 | * anchor strings almost always are. Multi-byte UTF-8 sequences are widened byte by byte, so non-ASCII | ||
| 1020 | * text never matches its true UTF-16LE encoding and in practice reports StringNotFound. | ||
| 1021 | * @warning XrefReturn::EnclosingFunction is a bounded heuristic prologue back-scan, not control-flow analysis; | ||
| 1022 | * prefer the default ReferencingInstruction when an exact site is acceptable. | ||
| 1023 | */ | ||
| 1024 | [[nodiscard]] std::expected<std::uintptr_t, StringXrefError> | ||
| 1025 | find_string_xref(const StringRefQuery &query, Memory::ModuleRange range = Memory::host_module_range()); | ||
| 1026 | |||
| 1027 | /** | ||
| 1028 | * @brief Cheap heuristic: does @p addr look like the first byte of a real function body? | ||
| 1029 | * @details Reads exactly one byte from @p addr under an SEH fault guard (Memory::seh_read) and rejects a small | ||
| 1030 | * blacklist of bytes that are never the first opcode of a callable x86-64 function: | ||
| 1031 | * | ||
| 1032 | * - 0x00 uninitialised page / zero-fill BSS / NULL page | ||
| 1033 | * - 0xCC int3 breakpoint / alignment pad / debugger trap | ||
| 1034 | * - 0xC2 0xC3 bare RET (stub, not a callable body) | ||
| 1035 | * | ||
| 1036 | * Returns true for every other byte, including 0xE9 / 0xEB / the 0xFF 0x25 prefix of an indirect JMP, | ||
| 1037 | * so a target whose prologue has already been overwritten by SafetyHook or MinHook still passes -- the | ||
| 1038 | * resolver must succeed for nested-hook scenarios. | ||
| 1039 | * | ||
| 1040 | * This is the negative complement to resolve_cascade_with_prologue_fallback(), which is a positive | ||
| 1041 | * recovery (rebuild the hooked-prologue pattern and retry). Both can be used together: the cascade | ||
| 1042 | * resolves the target, then this helper filters scan poison if the AOB happened to land on a zero page | ||
| 1043 | * or an alignment pad. | ||
| 1044 | * | ||
| 1045 | * @param addr Absolute address to probe. @p addr == 0 returns false without reading memory. An unreadable | ||
| 1046 | * address returns false (the byte could not be read, so the answer is "not a prologue"). | ||
| 1047 | * @return true if the byte at @p addr is not on the poison list and was readable; false otherwise. | ||
| 1048 | */ | ||
| 1049 | [[nodiscard]] bool is_likely_function_prologue(std::uintptr_t addr) noexcept; | ||
| 1050 | |||
| 1051 | /** | ||
| 1052 | * @enum SimdLevel | ||
| 1053 | * @brief Reports the highest SIMD tier available for pattern verification. | ||
| 1054 | */ | ||
| 1055 | enum class SimdLevel | ||
| 1056 | { | ||
| 1057 | /// No SIMD (byte-by-byte verification) | ||
| 1058 | Scalar, | ||
| 1059 | /// SSE2 (16 bytes per iteration) | ||
| 1060 | Sse2, | ||
| 1061 | /// AVX2 (32 bytes per iteration, with SSE2 + scalar tail) | ||
| 1062 | Avx2, | ||
| 1063 | /// AVX-512F + AVX-512BW (64 bytes/iteration). Opt-in: DMK_ENABLE_AVX512 build on an AVX-512 host. | ||
| 1064 | Avx512 | ||
| 1065 | }; | ||
| 1066 | |||
| 1067 | /** | ||
| 1068 | * @brief Returns the SIMD tier that find_pattern() will use at runtime. | ||
| 1069 | * @details Reflects both compile-time support (intrinsics available) and runtime CPU detection (CPUID + OS | ||
| 1070 | * XGETBV). Reports SimdLevel::Avx512 only when the library was built with the opt-in DMK_ENABLE_AVX512 | ||
| 1071 | * option and the host has AVX-512F + AVX-512BW; otherwise it reports the highest available lower tier | ||
| 1072 | * (AVX2, then SSE2, then Scalar). | ||
| 1073 | */ | ||
| 1074 | [[nodiscard]] SimdLevel active_simd_level() noexcept; | ||
| 1075 | |||
| 1076 | } // namespace Scanner | ||
| 1077 | } // namespace DetourModKit | ||
| 1078 | |||
| 1079 | #endif // DETOURMODKIT_SCANNER_HPP | ||
| 1080 |