GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 86.7% 52 / 0 / 60
Functions: 100.0% 7 / 0 / 7
Branches: 83.3% 20 / 0 / 24

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