./
Coverage:
low: ≥ 0%
medium: ≥ 75.0%
high: ≥ 90.0%
Lines:
111 / 112
99.1%
Branches:
59 / 71
83.1%
Functions:
38 / 38
100.0%

include/DetourModKit/scan.hpp
Line Branch Exec Source
1 #ifndef DETOURMODKIT_SCAN_HPP
2 #define DETOURMODKIT_SCAN_HPP
3
4 /**
5 * @file scan.hpp
6 * @brief Pattern matching, candidate-ladder resolution, and the RIP-relative, string-xref, code-constant, and export
7 * resolvers. docs/design/resolution.md owns the mechanism.
8 * @warning `[B-100]` Under the loader lock, call only Callback-safe entry points, with any Region argument built
9 * during setup. Ladder construction and resolution can allocate, scan memory, or create threads.
10 */
11
12 #include "DetourModKit/address.hpp"
13 #include "DetourModKit/defines.hpp"
14 #include "DetourModKit/detail/pattern_core.hpp"
15 #include "DetourModKit/error.hpp"
16 #include "DetourModKit/region.hpp"
17
18 #include <array>
19 #include <cstddef>
20 #include <cstdint>
21 #include <span>
22 #include <stdexcept>
23 #include <string>
24 #include <string_view>
25 #include <variant>
26 #include <vector>
27
28 namespace DetourModKit::scan
29 {
30 class Pattern;
31 } // namespace DetourModKit::scan
32
33 namespace DetourModKit::detail
34 {
35 /// Returns @p pattern's immutable compiled buffer for internal scan adapters.
36 [[nodiscard]] constexpr const PatternBuffer &pattern_buffer(const scan::Pattern &pattern) noexcept;
37 } // namespace DetourModKit::detail
38
39 namespace DetourModKit::scan
40 {
41 /**
42 * @brief A value-semantic compiled AOB pattern that owns its bytes and mask inline.
43 * @note Setup/control-plane only: compile(). Callback-safe: size(), the accessors, and matches_at().
44 */
45 class Pattern
46 {
47 public:
48 /**
49 * @brief Compiles a runtime AOB DSL string, for example "48 8B 05 ?? ?? ?? ??".
50 * @return The Pattern, or BadPattern (parse status in `extra`) for a malformed, empty, or oversized @p dsl.
51 * @note Setup/control-plane only.
52 */
53 20621 [[nodiscard]] static Result<Pattern> compile(std::string_view dsl)
54 {
55 20621 const detail::PatternParse parsed = detail::parse_pattern(dsl);
56
2/2
✓ Branch 3 → 4 taken 34 times.
✓ Branch 3 → 7 taken 20587 times.
20621 if (parsed.status != detail::PatternStatus::Ok)
57 {
58 34 return std::unexpected(
59 34 Error{ErrorCode::BadPattern, "scan::compile", 0, static_cast<std::uint32_t>(parsed.status)}
60 34 );
61 }
62 20587 return Pattern{parsed.buffer};
63 }
64
65 /** @brief Compiles an in-source AOB DSL literal at compile time. A malformed literal is a compile error. */
66 [[nodiscard]] static consteval Pattern literal(std::string_view dsl)
67 {
68 const detail::PatternParse parsed = detail::parse_pattern(dsl);
69 if (parsed.status != detail::PatternStatus::Ok)
70 {
71 throw "DetourModKit: scan::Pattern::literal() received a malformed AOB pattern";
72 }
73 return Pattern{parsed.buffer};
74 }
75
76 /// The fixed byte count, without gap bytes.
77 4195575 [[nodiscard]] constexpr std::size_t size() const noexcept { return m_data.length; }
78
79 /** @brief The `|` offset in fixed bytes, or 0 without a marker. A match result also adds skipped gap bytes. */
80 1087 [[nodiscard]] constexpr std::size_t offset() const noexcept { return m_data.offset; }
81
82 /// The fixed bytes of all segments, concatenated without gap bytes (length == size()).
83 2099420 [[nodiscard]] constexpr std::span<const std::byte> bytes() const noexcept
84 {
85 2099420 return std::span<const std::byte>(m_data.bytes.data(), m_data.length);
86 }
87
88 /// The per-byte match mask, parallel to bytes() (length == size()).
89 1531 [[nodiscard]] constexpr std::span<const std::byte> mask() const noexcept
90 {
91 1531 return std::span<const std::byte>(m_data.mask.data(), m_data.length);
92 }
93
94 /// True when the pattern carries at least one bounded jump.
95 72 [[nodiscard]] constexpr bool has_jumps() const noexcept { return m_data.jump_count > 0; }
96
97 /// Number of fixed segments.
98 5 [[nodiscard]] constexpr std::size_t segment_count() const noexcept { return m_data.jump_count + 1; }
99
100 /// Fewest bytes any match can occupy: the fixed byte count plus every gap's minimum skip.
101 5 [[nodiscard]] constexpr std::size_t min_match_length() const noexcept
102 {
103 5 return detail::min_match_length(m_data);
104 }
105
106 /// Most bytes any match can occupy: the fixed byte count plus every gap's maximum skip.
107 4 [[nodiscard]] constexpr std::size_t max_match_length() const noexcept
108 {
109 4 return detail::max_match_length(m_data);
110 }
111
112 /// True when the run before the first bounded jump has a fully-known byte for the prefilter anchor.
113 489 [[nodiscard]] constexpr bool has_anchor() const noexcept { return m_data.anchor < m_data.length; }
114
115 /// Index of the rarest fully-known byte before the first bounded jump, valid only when has_anchor() is true.
116 469 [[nodiscard]] constexpr std::size_t anchor_index() const noexcept { return m_data.anchor; }
117
118 /// The anchor byte value, or a zero byte when has_anchor() is false.
119 5 [[nodiscard]] constexpr std::byte anchor_byte() const noexcept
120 {
121
2/2
✓ Branch 3 → 4 taken 4 times.
✓ Branch 3 → 6 taken 1 time.
5 return has_anchor() ? m_data.bytes[m_data.anchor] : std::byte{0x00};
122 }
123
124 /**
125 * @brief Tests for a match at the start of @p window. A byte agrees when (memory ^ pattern) & mask is zero.
126 * @return True when the bounded search, shortest gaps first, places every segment. False also covers an
127 * exhausted per-position budget, so it is not proof of absence. A match needs min_match_length() bytes.
128 * @note Callback-safe: no allocation, I/O, or lock.
129 */
130 26 [[nodiscard]] constexpr bool matches_at(std::span<const std::byte> window) const noexcept
131 {
132 26 return detail::matches_buffer_at(m_data, window);
133 }
134
135 private:
136 friend constexpr const detail::PatternBuffer &detail::pattern_buffer(const Pattern &pattern) noexcept;
137
138 // Private: every Pattern comes from a successful compile() or literal() parse.
139 20587 constexpr explicit Pattern(const detail::PatternBuffer &data) noexcept : m_data{data} {}
140
141 detail::PatternBuffer m_data{};
142 };
143
144 } // namespace DetourModKit::scan
145
146 namespace DetourModKit::detail
147 {
148 /// Returns @p pattern's immutable compiled buffer for internal scan adapters.
149 41584 [[nodiscard]] constexpr const PatternBuffer &pattern_buffer(const scan::Pattern &pattern) noexcept
150 {
151 41584 return pattern.m_data;
152 }
153 } // namespace DetourModKit::detail
154
155 namespace DetourModKit::scan
156 {
157
158 /**
159 * @brief Which page-protection class a page-gated scan accepts.
160 * @details Readable authority rule: a Readable scan returns @ref ErrorCode::NotAuthoritative when its scope
161 * declares no exclusions and lies inside neither one mapped image nor one reserved allocation. Outside a
162 * mapped image, a scope in one allocation that crosses more than 64 `VirtualQuery` regions is unconfined.
163 * Such a scope also covers caller copies of the query bytes that DMK cannot enumerate, so a match can be
164 * the query storage itself. DMK always excludes the query storage that it owns.
165 *
166 * The remedies are a confined scope, an Executable scan, or exclusions that name every live caller copy.
167 * Proof: `ScannerTrustProof.WholeProcessReadableScanCannotAuthorizeQueryOwnedMatch`.
168 */
169 enum class Pages : std::uint8_t
170 {
171 /// Every committed readable page, a superset of Executable.
172 Readable,
173 /// Committed execute-readable pages only, for a pattern that must land on code.
174 Executable
175 };
176
177 /** @brief Largest x86-64 instruction length, in bytes. The RIP-relative helpers reject a longer instruction. */
178 inline constexpr std::size_t MAX_X86_INSTRUCTION_LENGTH = 15;
179
180 /**
181 * @brief Tests whether a disp32 at @p displacement_offset lies within @p instruction_length. A length above
182 * @ref MAX_X86_INSTRUCTION_LENGTH returns false. It checks bounds only, not the RIP-relative operand.
183 * @note Callback-safe: pure constexpr arithmetic.
184 */
185 [[nodiscard]] constexpr bool
186 152 is_valid_rip_relative_layout(std::size_t displacement_offset, std::size_t instruction_length) noexcept
187 {
188
3/4
✓ Branch 2 → 3 taken 148 times.
✓ Branch 2 → 6 taken 4 times.
✓ Branch 3 → 4 taken 148 times.
✗ Branch 3 → 6 not taken.
300 return instruction_length <= MAX_X86_INSTRUCTION_LENGTH && displacement_offset <= instruction_length &&
189
2/2
✓ Branch 4 → 5 taken 136 times.
✓ Branch 4 → 6 taken 12 times.
300 instruction_length - displacement_offset >= sizeof(std::int32_t);
190 }
191
192 /** @brief The highest SIMD verification tier the engine selects at runtime. */
193 enum class SimdLevel : std::uint8_t
194 {
195 /// Byte-by-byte verification (no SIMD).
196 Scalar,
197 /// SSE2.
198 Sse2,
199 /// AVX2.
200 Avx2,
201 /// AVX-512F and AVX-512BW, only in a DMK_ENABLE_AVX512 build on an AVX-512 host.
202 Avx512
203 };
204
205 /**
206 * @brief Returns the static enumerator name for a SimdLevel, or "Unknown" for an out-of-range value.
207 * @note Callback-safe: a pure constexpr value map.
208 */
209 [[nodiscard]] constexpr std::string_view to_string(SimdLevel level) noexcept
210 {
211 switch (level)
212 {
213 case SimdLevel::Scalar:
214 return "Scalar";
215 case SimdLevel::Sse2:
216 return "Sse2";
217 case SimdLevel::Avx2:
218 return "Avx2";
219 case SimdLevel::Avx512:
220 return "Avx512";
221 }
222 return "Unknown";
223 }
224
225 /**
226 * @brief Reports the SIMD tier that pattern matching uses, the highest that both the build and the host support.
227 * @note Callback-safe: a CPU-feature read with no allocation or lock.
228 */
229 [[nodiscard]] SimdLevel active_simd_level() noexcept;
230
231 /** @brief Byte encoding of an anchor string as it is stored in the image. */
232 enum class StringEncoding : std::uint8_t
233 {
234 /** @brief The query bytes verbatim, as a char literal stores them. Ill-formed UTF-8 is searched as given. */
235 Utf8,
236 /** @brief UTF-16LE (wchar_t), transcoded from the UTF-8 query. Ill-formed UTF-8 returns MalformedQueryText. */
237 Utf16le
238 };
239
240 /** @brief What a resolved string cross-reference returns. */
241 enum class XrefReturn : std::uint8_t
242 {
243 /// Exact address of the instruction that loads the string.
244 ReferencingInstruction,
245 /** @brief Enclosing-function entry from `.pdata` (chains to the primary), else a bounded RET/INT3 back-scan. */
246 EnclosingFunction,
247 /**
248 * @brief The global slot that a `mov [rip+slot], reg` fills shortly after the unique `lea reg, [rip+string]`.
249 * A `mov` load, a rarer shape that broad_match finds, or no such store returns StoreNotFound.
250 */
251 StringPointerSlot
252 };
253
254 /** @brief A string-reference query that borrows @ref text for one call. A Candidate::string_xref owns its copy. */
255 struct StringRefQuery
256 {
257 /** @brief Literal content without quotes. An embedded NUL returns @ref ErrorCode::MalformedQueryText. */
258 std::string_view text;
259 /// How the image stores the literal, and therefore how @ref text is read (see @ref StringEncoding).
260 StringEncoding encoding = StringEncoding::Utf8;
261 /// Match a trailing NUL, so "Player" does not match the prefix of "PlayerController".
262 bool require_terminator = true;
263 /// Selects the exact instruction site, the enclosing-function heuristic, or the cached global pointer slot.
264 XrefReturn return_mode = XrefReturn::ReferencingInstruction;
265 /**
266 * @brief With false, phase 2 finds only the REX.W `lea`/`mov reg, [rip+disp32]` shapes. With true, a Zydis
267 * sweep also finds rarer shapes such as `cmp [rip+d], imm`. That sweep decodes only for a displacement
268 * field whose arithmetic can reach the string. See find_string_xref() for the broad confirmation sweep.
269 */
270 bool broad_match = false;
271 };
272
273 /**
274 * @brief Resolves a string-reference anchor inside one mapped image.
275 * @param scope Module image to search, which must pass the Readable authority rule of @ref Pages.
276 * @return The address that @ref StringRefQuery::return_mode selects, or an Error.
277 * @details Phase 1 finds the one copy of @p query.text in readable pages, or returns @ref ErrorCode::StringNotFound
278 * or @ref ErrorCode::StringAmbiguous. Phase 2 finds the one RIP-relative reference to that copy, or
279 * returns @ref ErrorCode::NoReference or @ref ErrorCode::AmbiguousReference. An observed second copy or
280 * reference stays ambiguous in a truncated sweep. Any other truncated sweep returns
281 * @ref ErrorCode::IncompleteScan. Unencodable text returns @ref ErrorCode::MalformedQueryText, and an
282 * out-of-range encoding or return mode returns @ref ErrorCode::InvalidArg before phase 1 starts.
283 * @note Phase 1 excludes the storage of @p query.text. If that buffer is the only copy in scope, the result is
284 * @ref ErrorCode::StringNotFound. Anchor on a literal that the scanned image owns.
285 * @note With broad_match false, an EnclosingFunction or StringPointerSlot return confirms a single narrow hit with
286 * a broad sweep. A rarer second reference then returns @ref ErrorCode::AmbiguousReference, and a broad-only
287 * reference never becomes a hit.
288 * @note Setup/control-plane only. An allocation failure in either phase throws `std::bad_alloc`, never a miss.
289 */
290 [[nodiscard]] Result<Address> find_string_xref(const StringRefQuery &query, Region scope = Region::host());
291
292 /**
293 * @brief Resolves a named export to its address through one module's PE Export Address Table.
294 * @param export_name The exact, case-sensitive export name, for example "Sleep".
295 * @param module The image that exports the name, for example @ref Region::module_named("kernel32.dll").
296 * @details The walk parses the mapped IMAGE_EXPORT_DIRECTORY and never calls GetProcAddress, so it never enters the
297 * loader or runs a DllMain. A truncated or hostile export section returns an Error, unless the guard-page
298 * re-arm in `[B-20]` fails. A missing export directory, an absent or duplicate name, an ordinal-only
299 * export, an out-of-image RVA, or an empty @p export_name returns ExportNotFound. A null or invalid module
300 * image returns InvalidRange. A forwarded export returns ExportForwarded.
301 * @note Setup/control-plane only by convention. It does not allocate.
302 */
303 [[nodiscard]] Result<Address> resolve_export(std::string_view export_name, Region module = Region::host()) noexcept;
304
305 /** @brief Which operand field @ref read_code_constant extracts. */
306 enum class OperandKind : std::uint8_t
307 {
308 /// An immediate operand, for example the imm of `add reg, imm`.
309 Immediate,
310 /// A memory operand's displacement, for example the disp of `[reg + disp]`.
311 MemoryDisplacement
312 };
313
314 /** @brief The resolution strategy of a Candidate. RttiVtable and StringXref are unique-only text tiers. */
315 enum class Mode : std::uint8_t
316 {
317 /// Scan for the Pattern, then add a fixed signed walk-back to the match.
318 Direct,
319 /// Scan for the Pattern, then read the RIP-relative disp32 it spans and compute the absolute target.
320 RipRelative,
321 /// Resolve the primary vtable of an MSVC-mangled type name through the reverse-RTTI walk.
322 RttiVtable,
323 /// Anchor on an immutable string literal and resolve the unique RIP-relative reference to it.
324 StringXref
325 };
326
327 /** @brief The ladder order before resolution. It can change which @ref Hit wins, never the verification rules. */
328 enum class CandidateOrder : std::uint8_t
329 {
330 /// Try candidates in the order the caller wrote them.
331 AsDeclared,
332 /// Try unique-only text tiers, then anchored byte patterns, then the rest, each group in declared order.
333 UniqueFirst
334 };
335
336 /**
337 * @brief Returns the static enumerator name for a CandidateOrder, or "Unknown" for an out-of-range value.
338 * @note Callback-safe: a pure constexpr value map.
339 */
340 5 [[nodiscard]] constexpr std::string_view candidate_order_to_string(CandidateOrder order) noexcept
341 {
342
3/3
✓ Branch 2 → 3 taken 2 times.
✓ Branch 2 → 4 taken 2 times.
✓ Branch 2 → 5 taken 1 time.
5 switch (order)
343 {
344 2 case CandidateOrder::AsDeclared:
345 2 return "AsDeclared";
346 2 case CandidateOrder::UniqueFirst:
347 2 return "UniqueFirst";
348 }
349 1 return "Unknown";
350 }
351
352 /** @brief The Direct-tier payload: a compiled Pattern plus the signed walk-back applied to the match. */
353 struct DirectPattern
354 {
355 /// The compiled pattern to scan for.
356 Pattern pattern;
357 /// Signed byte delta added to the match, where a negative value walks backward.
358 std::ptrdiff_t walk_back{0};
359 };
360
361 /** @brief The RipRelative-tier payload. It targets `match + instruction_length + disp32`. A bad disp32 misses. */
362 struct RipRelativePattern
363 {
364 /// The compiled pattern to scan for.
365 Pattern pattern;
366 /// Byte offset from the match to the signed 4-byte displacement field.
367 std::ptrdiff_t displacement_at{0};
368 /// Total length of the referencing instruction (the next-IP base for the disp).
369 std::size_t instruction_length{0};
370 };
371
372 /** @brief The RttiVtable-tier payload: an owned mangled type name. An ambiguous name fails closed. */
373 struct RttiVtable
374 {
375 /// The MSVC decorated type name, for example ".?AVCameraManager@@".
376 std::string mangled;
377 };
378
379 /** @brief The StringXref-tier payload: owned literal and facets. A second copy or reference fails closed. */
380 struct StringXref
381 {
382 /// The exact string content to anchor on, without quotes.
383 std::string text;
384 /// How the literal is stored in the image.
385 StringEncoding encoding{StringEncoding::Utf8};
386 /// Match a trailing NUL so a prefix of a longer literal is not matched.
387 bool require_terminator{true};
388 /// What the resolution returns (see @ref XrefReturn).
389 XrefReturn return_mode{XrefReturn::ReferencingInstruction};
390 /// Add the Zydis broad sweep (see @ref StringRefQuery::broad_match).
391 bool broad_match{false};
392 };
393
394 /** @brief One ladder tier, built only by the factories. It copies its strings, so it never aliases the caller. */
395 class Candidate
396 {
397 public:
398 /// The variant payload, whose alternative order matches the Mode enumerator order.
399 using Payload = std::variant<DirectPattern, RipRelativePattern, RttiVtable, StringXref>;
400
401 /**
402 * @brief A Direct byte-scan candidate: the resolved address is the match plus a fixed walk-back.
403 * @note Setup/control-plane only.
404 */
405 20539 [[nodiscard]] static Candidate direct(std::string name, Pattern pattern, std::ptrdiff_t walk_back = 0)
406 {
407 41078 return Candidate{std::move(name), DirectPattern{std::move(pattern), walk_back}};
408 }
409
410 /**
411 * @brief A RIP-relative byte-scan candidate: the address is the target of the disp32 that the match spans.
412 * @throws std::invalid_argument for an invalid layout: a negative @p displacement_at, a disp32 that ends past
413 * @p instruction_length, or a length above 15. It also throws for a pattern suffix from `|` that does
414 * not span the disp32.
415 * @details The matched instruction must decode to @p instruction_length bytes, with a RIP-relative memory
416 * disp32 at @p displacement_at, or the candidate never resolves. A rel32 branch (`E8` call, `E9` jmp)
417 * has none. For a branch target, use scan() plus resolve_rip_relative(). The resolver computes the
418 * target from one immutable sweep snapshot, never from a reread after the sweep.
419 * @note Setup/control-plane only. The manifest loader checks the same layout and returns an error, not a throw.
420 */
421 [[nodiscard]] static Candidate
422 47 rip_relative(std::string name, Pattern pattern, std::ptrdiff_t displacement_at, std::size_t instruction_length)
423 {
424 93 if (displacement_at < 0 ||
425
6/6
✓ Branch 2 → 3 taken 46 times.
✓ Branch 2 → 8 taken 1 time.
✓ Branch 4 → 5 taken 43 times.
✓ Branch 4 → 8 taken 3 times.
✓ Branch 10 → 11 taken 8 times.
✓ Branch 10 → 14 taken 39 times.
90 !is_valid_rip_relative_layout(static_cast<std::size_t>(displacement_at), instruction_length) ||
426 43 detail::min_match_suffix_length(detail::pattern_buffer(pattern)) <
427
2/2
✓ Branch 7 → 8 taken 4 times.
✓ Branch 7 → 9 taken 39 times.
43 static_cast<std::size_t>(displacement_at) + sizeof(std::int32_t))
428 {
429 throw std::invalid_argument(
430 "scan::Candidate::rip_relative: the matched suffix must span a valid x86-64 RIP disp32 "
431 "(0 <= displacement_at, displacement_at + 4 <= instruction_length <= 15, and the pattern's "
432 "shortest suffix from the result marker covers displacement_at + 4 bytes)"
433
1/2
✓ Branch 12 → 13 taken 8 times.
✗ Branch 12 → 25 not taken.
8 );
434 }
435 return Candidate{
436 39 std::move(name),
437 78 RipRelativePattern{std::move(pattern), displacement_at, instruction_length}
438 117 };
439 }
440
441 /**
442 * @brief An RTTI-vtable candidate: resolves the primary vtable of an MSVC-mangled type name.
443 * @note Setup/control-plane only.
444 */
445 15 [[nodiscard]] static Candidate rtti_vtable(std::string name, std::string mangled)
446 {
447 30 return Candidate{std::move(name), RttiVtable{std::move(mangled)}};
448 }
449
450 /**
451 * @brief A string-xref candidate with the default @ref StringXref facets.
452 * @note Setup/control-plane only.
453 */
454 12 [[nodiscard]] static Candidate string_xref(std::string name, std::string literal)
455 {
456
1/2
✗ Branch 12 → 13 not taken.
✓ Branch 12 → 14 taken 12 times.
24 return Candidate{std::move(name), StringXref{std::move(literal)}};
457 }
458
459 /**
460 * @brief A string-xref candidate with the facets of @p query. It owns a copy of @p query.text.
461 * @note Setup/control-plane only.
462 */
463 19 [[nodiscard]] static Candidate string_xref(std::string name, StringRefQuery query)
464 {
465 return Candidate{
466 19 std::move(name),
467
2/4
✓ Branch 7 → 8 taken 19 times.
✗ Branch 7 → 18 not taken.
✗ Branch 12 → 13 not taken.
✓ Branch 12 → 14 taken 19 times.
38 StringXref{
468 ✗ std::string{query.text},
469 19 query.encoding,
470 19 query.require_terminator,
471 19 query.return_mode,
472 19 query.broad_match
473 }
474 57 };
475 }
476
477 /// Human-readable label, copied into the winning Hit.
478 20408 [[nodiscard]] const std::string &name() const noexcept { return m_name; }
479
480 /// The resolution strategy of this tier.
481 20729 [[nodiscard]] Mode mode() const noexcept { return static_cast<Mode>(m_payload.index()); }
482
483 /// The full variant payload.
484 [[nodiscard]] const Payload &payload() const noexcept { return m_payload; }
485
486 /// Returns the Direct payload, or nullptr when this is not a Direct candidate.
487 21473 [[nodiscard]] const DirectPattern *as_direct() const noexcept { return std::get_if<DirectPattern>(&m_payload); }
488
489 /// Returns the RipRelative payload, or nullptr when this is not a RipRelative candidate.
490 478 [[nodiscard]] const RipRelativePattern *as_rip_relative() const noexcept
491 {
492 478 return std::get_if<RipRelativePattern>(&m_payload);
493 }
494
495 /// Returns the RttiVtable payload, or nullptr when this is not an RTTI-vtable candidate.
496 911 [[nodiscard]] const RttiVtable *as_rtti_vtable() const noexcept { return std::get_if<RttiVtable>(&m_payload); }
497
498 /// Returns the StringXref payload, or nullptr when this is not a string-xref candidate.
499 21413 [[nodiscard]] const StringXref *as_string_xref() const noexcept { return std::get_if<StringXref>(&m_payload); }
500
501 private:
502 // Private: every Candidate comes from a factory, so its name and payload stay coherent.
503 61872 Candidate(std::string name, Payload payload) : m_name{std::move(name)}, m_payload{std::move(payload)} {}
504
505 std::string m_name;
506 Payload m_payload;
507 };
508
509 // mode() casts the variant index, so the alternative order must match the Mode order.
510 static_assert(std::is_same_v<
511 std::variant_alternative_t<static_cast<std::size_t>(Mode::Direct), Candidate::Payload>,
512 DirectPattern>);
513 static_assert(std::is_same_v<
514 std::variant_alternative_t<static_cast<std::size_t>(Mode::RipRelative), Candidate::Payload>,
515 RipRelativePattern>);
516 static_assert(std::is_same_v<
517 std::variant_alternative_t<static_cast<std::size_t>(Mode::RttiVtable), Candidate::Payload>,
518 RttiVtable>);
519 static_assert(std::is_same_v<
520 std::variant_alternative_t<static_cast<std::size_t>(Mode::StringXref), Candidate::Payload>,
521 StringXref>);
522
523 /** @brief Declares a constant encoded in machine code, so read_code_constant() can re-derive it after a patch. */
524 struct CodeConstant
525 {
526 /// Borrowed candidate ladder that resolves to an execute-readable instruction site.
527 std::span<const Candidate> site;
528 /// Which operand field to read: an immediate or a memory displacement.
529 OperandKind kind = OperandKind::Immediate;
530 /// Index into the instruction's visible operands, as a disassembler counts them.
531 std::uint8_t operand_index = 0;
532 /// 0 keeps the decoded value, and 1 through 8 narrows a non-RIP constant to its low bytes and sign-extends it.
533 std::uint8_t byte_width = 0;
534 /// Last-known value for telemetry or a baseline, never returned in place of a live decode.
535 std::int64_t nominal = 0;
536 /// True when @ref nominal holds a value, so nominal == 0 does not mean "unset".
537 bool has_nominal = false;
538 };
539
540 /**
541 * @brief Resolves @p code_constant.site, decodes the instruction there, and returns the requested operand's value.
542 * @return The sign-extended live value, or an Error. A RIP-relative memory operand returns its absolute target.
543 * @details A candidate whose final site is not execute-readable is skipped, and the next candidate runs. A selected
544 * site that loses execute protection, crosses into a non-executable page, or no longer decodes returns
545 * DecodeFailed. A wrong operand kind returns UnexpectedShape, and an operand index out of range returns
546 * OperandOutOfRange. An out-of-range kind or byte_width returns @ref ErrorCode::InvalidArg first.
547 * @note `[B-75]` The value decodes from a fresh snapshot after site resolution. A byte tier must still match its
548 * physical span and resolve the decoded site at that epoch, or the result is EvidenceMismatch. A wildcarded
549 * operand byte can drift, and the result is its current value.
550 * @note Setup/control-plane only. Site resolution allocates.
551 */
552 [[nodiscard]] Result<std::int64_t>
553 read_code_constant(const CodeConstant &code_constant, Region scope = Region::host());
554
555 /**
556 * @brief Ceiling on the bytes a @ref WinningEvidence carries. A longer span still resolves, but its truncated
557 * evidence never authorizes a mutation or seeds a strict baseline.
558 */
559 inline constexpr std::size_t MAX_MUTATION_WITNESS_BYTES = 256;
560
561 /**
562 * @brief The literal bytes at the winning match span, captured by the match and never re-read. They include the
563 * wildcard and gap bytes, so they detect a same-layout code change that @ref ImageIdentity cannot see.
564 */
565 struct WinningEvidence
566 {
567 /// The captured span in the first @ref length elements, with zero in the trailing elements.
568 std::array<std::byte, MAX_MUTATION_WITNESS_BYTES> bytes{};
569 /// The count of valid leading elements in @ref bytes, or 0 when nothing was captured.
570 std::uint16_t length = 0;
571 /** @brief True when the winning span exceeded @ref MAX_MUTATION_WITNESS_BYTES. @ref length is then 0. */
572 bool truncated = false;
573
574 /// True when a complete, internally valid winning span was captured.
575 516 [[nodiscard]] constexpr bool present() const noexcept
576 {
577
6/6
✓ Branch 2 → 3 taken 71 times.
✓ Branch 2 → 6 taken 445 times.
✓ Branch 3 → 4 taken 69 times.
✓ Branch 3 → 6 taken 2 times.
✓ Branch 4 → 5 taken 67 times.
✓ Branch 4 → 6 taken 2 times.
516 return length != 0 && length <= MAX_MUTATION_WITNESS_BYTES && !truncated;
578 }
579
580 /// The captured bytes as a span, empty unless @ref present.
581 7 [[nodiscard]] constexpr std::span<const std::byte> span() const noexcept
582 {
583
2/2
✓ Branch 3 → 4 taken 2 times.
✓ Branch 3 → 5 taken 5 times.
7 if (!present())
584 {
585 2 return {};
586 }
587 5 return std::span<const std::byte>{bytes.data(), length};
588 }
589
590 /** @brief Value equality. A malformed value (bad @ref length, or bytes while @ref truncated) equals nothing. */
591 15 [[nodiscard]] constexpr bool operator==(const WinningEvidence &other) const noexcept
592 {
593
4/6
✓ Branch 2 → 3 taken 15 times.
✗ Branch 2 → 5 not taken.
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 6 taken 14 times.
✓ Branch 4 → 5 taken 1 time.
✗ Branch 4 → 6 not taken.
15 const bool malformed = length > MAX_MUTATION_WITNESS_BYTES || (truncated && length != 0);
594 15 const bool other_malformed =
595
4/6
✓ Branch 7 → 8 taken 15 times.
✗ Branch 7 → 10 not taken.
✓ Branch 8 → 9 taken 1 time.
✓ Branch 8 → 11 taken 14 times.
✓ Branch 9 → 10 taken 1 time.
✗ Branch 9 → 11 not taken.
15 other.length > MAX_MUTATION_WITNESS_BYTES || (other.truncated && other.length != 0);
596
3/4
✓ Branch 12 → 13 taken 14 times.
✓ Branch 12 → 14 taken 1 time.
✗ Branch 13 → 14 not taken.
✓ Branch 13 → 15 taken 14 times.
15 if (malformed || other_malformed)
597 {
598 1 return false;
599 }
600
3/4
✓ Branch 15 → 16 taken 13 times.
✓ Branch 15 → 17 taken 1 time.
✗ Branch 16 → 17 not taken.
✓ Branch 16 → 18 taken 13 times.
14 if (length != other.length || truncated != other.truncated)
601 {
602 1 return false;
603 }
604
2/2
✓ Branch 24 → 19 taken 1078 times.
✓ Branch 24 → 25 taken 11 times.
1089 for (std::size_t i = 0; i < length; ++i)
605 {
606
2/2
✓ Branch 21 → 22 taken 2 times.
✓ Branch 21 → 23 taken 1076 times.
1078 if (bytes[i] != other.bytes[i])
607 {
608 2 return false;
609 }
610 }
611 11 return true;
612 }
613 };
614
615 /** @brief A resolved address with the owned name and the mode of the candidate that produced it. */
616 struct Hit
617 {
618 /// The resolved absolute address.
619 Address address;
620 /// A copy of the winning candidate's name.
621 std::string winning_name;
622 /// The resolution mode of the winning candidate.
623 Mode winning_mode = Mode::Direct;
624 /** @brief Matched-span bytes. Only a byte-pattern tier fills them, so no other tier can seed a baseline. */
625 WinningEvidence evidence{};
626 };
627
628 /** @brief `[B-53]` How strictly hooked-prologue recovery, which is address-blind, confirms a recovered target. */
629 enum class FallbackPolicy : std::uint8_t
630 {
631 /// Recovery disabled: a full direct miss stays a miss.
632 Off,
633 /// Recover structurally and return the address. A @ref FallbackWitness rejection only logs a warning.
634 WarnOnly,
635 /// Recover structurally, then require a @ref FallbackWitness to confirm the recovered address.
636 RequireIdentity,
637 };
638
639 /** @brief Returns false to reject a recovered site. It has the same type as @ref anchor::AnchorValidator. */
640 using FallbackValidator = bool (*)(std::int64_t value, const void *context) noexcept;
641
642 /** @brief The FallbackPolicy witness. With a null predicate, WarnOnly recovers and RequireIdentity fails closed. */
643 struct FallbackWitness
644 {
645 /// Predicate run on the recovered address, or nullptr for no identity check.
646 FallbackValidator predicate = nullptr;
647 /// Opaque pointer forwarded verbatim to @ref predicate.
648 const void *context = nullptr;
649 };
650
651 /** @brief A non-owning resolution request for use within one expression. To store one, use OwnedScanRequest. */
652 struct ScanRequest
653 {
654 /// Candidates tried in @ref order, where the first one that resolves wins.
655 std::span<const Candidate> ladder;
656 /// Optional non-owning label for diagnostics.
657 std::string_view label{};
658 /// The memory range to resolve within.
659 Region scope = Region::host();
660 /// Hooked-prologue recovery strictness on a full direct miss (see @ref FallbackPolicy).
661 FallbackPolicy fallback_policy = FallbackPolicy::Off;
662 /// The identity witness the fallback runs on a recovered site (see @ref FallbackPolicy). Unused when Off.
663 FallbackWitness fallback_witness{};
664 /// Fail closed on an ambiguous byte match (a second occurrence in scope). False takes the first match.
665 bool require_unique = true;
666 /// How the ladder is ordered before it is tried.
667 CandidateOrder order = CandidateOrder::AsDeclared;
668 /// Page-protection class the Direct / RipRelative byte tiers scan.
669 Pages pages = Pages::Readable;
670 /**
671 * @brief Rejects each candidate or recovered site whose final address is off an execute-readable page. A
672 * rejected candidate is skipped, not fatal. A RIP-relative data global needs false.
673 */
674 bool require_executable_result = false;
675 /**
676 * @brief Every live caller copy of the query bytes. The byte and string-xref tiers do not count a match that
677 * intersects one. A non-empty span satisfies the Readable authority rule.
678 */
679 std::span<const Region> exclusions{};
680 };
681
682 /**
683 * @brief Builds a borrowed ScanRequest. Clang and MSVC code analysis can flag a temporary ladder or label.
684 * @note Callback-safe with an explicit Region: no allocation. The default scope query is setup/control-plane only.
685 */
686 [[nodiscard]] ScanRequest borrow(
687 std::span<const Candidate> ladder DMK_LIFETIMEBOUND,
688 std::string_view label DMK_LIFETIMEBOUND = {},
689 Region scope = Region::host(),
690 FallbackPolicy fallback_policy = FallbackPolicy::Off,
691 FallbackWitness fallback_witness = {},
692 bool require_unique = true,
693 CandidateOrder order = CandidateOrder::AsDeclared,
694 Pages pages = Pages::Readable
695 ) noexcept;
696
697 /**
698 * @brief Builds a borrowed ScanRequest preset for a code (hook) target.
699 * @details The preset sets `Pages::Executable`, `require_executable_result`, and @ref CandidateOrder::UniqueFirst.
700 * For a data, RTTI, or string target, use @ref borrow.
701 * @note Callback-safe with an explicit Region: no allocation. The default scope query is setup/control-plane only.
702 */
703 [[nodiscard]] ScanRequest borrow_code_target(
704 std::span<const Candidate> ladder DMK_LIFETIMEBOUND,
705 std::string_view label DMK_LIFETIMEBOUND = {},
706 Region scope = Region::host(),
707 FallbackPolicy fallback_policy = FallbackPolicy::WarnOnly,
708 FallbackWitness fallback_witness = {}
709 ) noexcept;
710
711 /**
712 * @brief The @ref borrow_code_target preset under @ref FallbackPolicy::RequireIdentity. For no label, pass {}.
713 * @note Callback-safe with an explicit Region: no allocation. The default scope query is setup/control-plane only.
714 */
715 [[nodiscard]] ScanRequest borrow_code_target_strict(
716 std::span<const Candidate> ladder DMK_LIFETIMEBOUND,
717 std::string_view label DMK_LIFETIMEBOUND,
718 FallbackWitness fallback_witness,
719 Region scope = Region::host()
720 ) noexcept;
721
722 /** @brief An owning resolution request for stored or deferred resolution. Stored entry points take this type. */
723 struct OwnedScanRequest
724 {
725 /// Owned candidate ladder.
726 std::vector<Candidate> ladder;
727 /// Owned diagnostic label.
728 std::string label;
729 /// The resolution scope.
730 Region scope = Region::host();
731 /// Hooked-prologue recovery strictness on a full direct miss (see @ref FallbackPolicy).
732 FallbackPolicy fallback_policy = FallbackPolicy::Off;
733 /// The identity witness the fallback runs on a recovered site (see @ref FallbackPolicy). Unused when Off.
734 FallbackWitness fallback_witness{};
735 /// Fail closed on an ambiguous byte match.
736 bool require_unique = true;
737 /// Ladder ordering policy.
738 CandidateOrder order = CandidateOrder::AsDeclared;
739 /// Page-protection class the byte tiers scan (see @ref ScanRequest::pages).
740 Pages pages = Pages::Readable;
741 /// Whether the final resolved address must be execute-readable.
742 bool require_executable_result = false;
743 /// Owned copies of the caller-declared query exclusions (see @ref ScanRequest::exclusions).
744 std::vector<Region> exclusions;
745
746 /** @brief Returns a ScanRequest that views this object's storage, valid only while this object lives. */
747 12 [[nodiscard]] ScanRequest view() const noexcept DMK_LIFETIMEBOUND
748 {
749 return ScanRequest{
750 12 .ladder = ladder,
751 12 .label = label,
752 .scope = scope,
753 12 .fallback_policy = fallback_policy,
754 .fallback_witness = fallback_witness,
755 12 .require_unique = require_unique,
756 12 .order = order,
757 12 .pages = pages,
758 12 .require_executable_result = require_executable_result,
759 12 .exclusions = exclusions,
760 24 };
761 }
762 };
763
764 /**
765 * @brief Writes the index permutation that @p order implies for @p ladder into @p out.
766 * @return The number of indices written, at most min(ladder.size(), out.size()).
767 * @details AsDeclared and any out-of-range value yield the identity permutation.
768 * @note Callback-safe: pure index math with no allocation.
769 */
770 [[nodiscard]] std::size_t
771 order_candidates(CandidateOrder order, std::span<const Candidate> ladder, std::span<std::size_t> out) noexcept;
772
773 /**
774 * @brief Returns the Hit of the first candidate, in @ref ScanRequest::order, that resolves, or an Error.
775 * @details A byte tier resolves when it matches in scope, passes the uniqueness gate when required, and yields an
776 * in-scope plausible address. A text tier resolves through its unique-only backend. After a full direct
777 * miss with a non-Off fallback_policy, the resolver rebuilds each Direct prologue as a near or far JMP.
778 * Only a unique match of the rebuilt shape resolves, regardless of require_unique. A byte tier whose own
779 * sweep was truncated (BudgetExceeded or IncompleteScan) preempts that recovery. A recovery sweep that
780 * skips a faulted region reports IncompleteScan, not a miss.
781 *
782 * A text tier failure does not preempt recovery. That failure is MalformedQueryText, or NotAuthoritative
783 * or IncompleteScan from its own readable sweep. If nothing resolves, the result reports it in place of
784 * the generic miss. A scope that fails the Readable authority rule of @ref Pages refuses the request
785 * before the resolver grades any candidate. An out-of-range pages, order, fallback_policy, or StringXref
786 * encoding or return mode returns @ref ErrorCode::InvalidArg. Only an allocation failure throws.
787 * @note Setup/control-plane only.
788 */
789 [[nodiscard]] Result<Hit> resolve(const ScanRequest &request);
790
791 /**
792 * @brief Resolves a batch of requests concurrently and returns one Result per request in input order.
793 * @param max_workers Upper bound on worker threads, or 0 to select from the hardware concurrency.
794 * @return The outer Result fails with OutOfMemory if the result vector cannot be allocated, and with Unknown on any
795 * other whole-batch exception. A slot holds OutOfMemory after its own allocation failure, and NoMatch after
796 * any other exception.
797 * @note Setup/control-plane only: it spawns a worker pool and allocates.
798 */
799 [[nodiscard]] Result<std::vector<Result<Hit>>>
800 resolve_batch(std::span<const ScanRequest> requests, std::size_t max_workers = 0) noexcept;
801
802 /**
803 * @brief Scans one Pattern over a known scope and returns the Nth match address.
804 * @return The @p occurrence-th (1-based) match, adjusted by the `|` offset, or an Error. 0 returns NoMatch.
805 * @details The sweep reads only committed pages of the @p pages class. It skips an unmapped or guard page without a
806 * host fault and still finds a match that straddles two adjacent accepted regions. Each call repeats its
807 * setup, which can query the loader for the authority check. For a cursor walk over a range that the
808 * caller proves readable, use @ref unchecked::find_pattern.
809 *
810 * @ref ErrorCode::NoMatch means the sweep read the whole scope and found fewer than @p occurrence matches.
811 * @ref ErrorCode::IncompleteScan means the sweep skipped a faulted region, so the pattern can live in
812 * unread bytes. @ref ErrorCode::BudgetExceeded means a bounded-jump pattern spent its backtracking budget.
813 * Neither truncation is a miss. An out-of-range @p pages returns @ref ErrorCode::InvalidArg before the
814 * sweep starts. A Readable scan follows the Readable authority rule of @ref Pages.
815 * @note Setup/control-plane only. An allocation failure while the scan is prepared returns OutOfMemory.
816 */
817 [[nodiscard]] Result<Address>
818 scan(const Pattern &pattern, Region scope, std::size_t occurrence = 1, Pages pages = Pages::Readable) noexcept;
819
820 /**
821 * @brief The four-argument scan() that does not count a match that intersects one of @p exclusions.
822 * @param exclusions The live caller copies of the query bytes, as @ref ScanRequest::exclusions describes.
823 * @details DMK adds @p exclusions to its own query-storage exclusions. The combined set has 32 slots after touching
824 * spans merge. If the set cannot hold every span, the result is @ref ErrorCode::NotAuthoritative.
825 * @note Setup/control-plane only.
826 */
827 [[nodiscard]] Result<Address> scan(
828 const Pattern &pattern,
829 Region scope,
830 std::span<const Region> exclusions,
831 std::size_t occurrence = 1,
832 Pages pages = Pages::Readable
833 ) noexcept;
834
835 /// Common x86-64 opcode prefixes, the bytes before the disp32 field, for find_and_resolve_rip_relative().
836 inline constexpr std::array<std::byte, 3> PREFIX_MOV_RAX_RIP = {std::byte{0x48}, std::byte{0x8B}, std::byte{0x05}};
837 inline constexpr std::array<std::byte, 3> PREFIX_MOV_RCX_RIP = {std::byte{0x48}, std::byte{0x8B}, std::byte{0x0D}};
838 inline constexpr std::array<std::byte, 3> PREFIX_MOV_RDX_RIP = {std::byte{0x48}, std::byte{0x8B}, std::byte{0x15}};
839 inline constexpr std::array<std::byte, 3> PREFIX_MOV_RBX_RIP = {std::byte{0x48}, std::byte{0x8B}, std::byte{0x1D}};
840 inline constexpr std::array<std::byte, 3> PREFIX_LEA_RAX_RIP = {std::byte{0x48}, std::byte{0x8D}, std::byte{0x05}};
841 inline constexpr std::array<std::byte, 3> PREFIX_LEA_RCX_RIP = {std::byte{0x48}, std::byte{0x8D}, std::byte{0x0D}};
842 inline constexpr std::array<std::byte, 3> PREFIX_LEA_RDX_RIP = {std::byte{0x48}, std::byte{0x8D}, std::byte{0x15}};
843 inline constexpr std::array<std::byte, 1> PREFIX_CALL_REL32 = {std::byte{0xE8}};
844 inline constexpr std::array<std::byte, 1> PREFIX_JMP_REL32 = {std::byte{0xE9}};
845
846 /**
847 * @brief Resolves `instruction + instruction_length + disp32` for an x86-64 RIP-relative instruction.
848 * @details An invalid layout returns InvalidArg before a read. A target that is not a plausible user-mode address
849 * returns ImplausibleTarget. The `FF 15` and `FF 25` shapes return the pointer slot.
850 * @note Callback-safe: a guarded read and pointer arithmetic, with no allocation.
851 */
852 [[nodiscard]] Result<Address>
853 resolve_rip_relative(Address instruction, std::size_t displacement_offset, std::size_t instruction_length) noexcept;
854
855 /**
856 * @brief Scans @p search for @p opcode_prefix and resolves the disp32 after the first resolvable occurrence.
857 * @param instruction_length Total instruction length, at most 15. The disp32 immediately follows @p opcode_prefix.
858 * @details An occurrence is a decoy when its target is implausible or the first target byte is unreadable at scan
859 * time. The scan skips each decoy. After it exhausts the region, it reports the last concrete failure,
860 * for example @ref ErrorCode::ImplausibleTarget or @ref ErrorCode::UnreadableTarget. The `FF 15` and
861 * `FF 25` shapes return the pointer slot. An invalid layout returns @ref ErrorCode::InvalidArg before the
862 * sweep starts.
863 * @note The prefix search reads @p search unguarded, so it must be readable. While the memory cache runs, the
864 * one-byte @ref memory::is_readable target check can allocate a cache entry. A failed allocation leaves the
865 * result unchanged.
866 * @note Setup/control-plane only: the sweep cost scales with @p search.
867 */
868 [[nodiscard]] Result<Address> find_and_resolve_rip_relative(
869 Region search,
870 std::span<const std::byte> opcode_prefix,
871 std::size_t instruction_length
872 ) noexcept;
873
874 /**
875 * @brief Cheap heuristic: tests whether @p addr looks like the first byte of a real function body.
876 * @return True when @p addr is non-null and its guarded byte is readable and not 0x00, 0xCC, 0xC2, or 0xC3.
877 * @details A jump opcode (0xE9, 0xEB, or the 0xFF of `FF 25`) passes, so a prologue that a hook rewrote passes.
878 * @note Callback-safe: a single guarded byte read, no allocation.
879 */
880 [[nodiscard]] bool is_likely_function_prologue(Address addr) noexcept;
881
882 /**
883 * @brief An ASLR-insensitive fingerprint of a loaded module's PE timestamp, image size, and section-table layout.
884 * @warning This is layout identity, not content identity. It reads no section body, so code patched in place under
885 * equal headers keeps a bit-identical identity. Use @ref WinningEvidence to witness content.
886 */
887 struct ImageIdentity
888 {
889 /// PE @c IMAGE_FILE_HEADER::TimeDateStamp of the resolved image (0 when the read failed).
890 482 std::uint32_t timestamp = 0;
891 /// PE @c IMAGE_OPTIONAL_HEADER::SizeOfImage of the resolved image (0 when the read failed).
892 480 std::uint32_t size_of_image = 0;
893 /// A fold of every section header's name, RVA, virtual size, and characteristics.
894 480 std::uint64_t section_digest = 0;
895
896 /// True when a live image was read.
897 749 [[nodiscard]] constexpr bool present() const noexcept { return size_of_image != 0; }
898
899 /// A 64-bit token that folds all three fields, for a fingerprint or an equality key.
900 15 [[nodiscard]] constexpr std::uint64_t token() const noexcept
901 {
902 15 std::uint64_t seed = section_digest;
903 15 seed ^= static_cast<std::uint64_t>(timestamp) + 0x9E3779B97F4A7C15ULL + (seed << 6) + (seed >> 2);
904 15 seed ^= static_cast<std::uint64_t>(size_of_image) + 0x9E3779B97F4A7C15ULL + (seed << 6) + (seed >> 2);
905 15 return seed;
906 }
907
908 /// Value equality across all three fields.
909
5/6
✓ Branch 2 → 3 taken 2 times.
✓ Branch 2 → 4 taken 480 times.
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 480 times.
✓ Branch 6 → 7 taken 12 times.
✓ Branch 6 → 8 taken 468 times.
482 [[nodiscard]] constexpr bool operator==(const ImageIdentity &other) const noexcept = default;
910 };
911
912 /**
913 * @brief Reads the @ref ImageIdentity of the module mapped at @p range.base. The extent comes from its PE headers.
914 * @return The identity, or an absent value when @p range is empty or its PE headers do not validate completely.
915 * @note Callback-safe with an explicit Region: guarded PE header reads, with no allocation or loader call. The
916 * default scope query is setup/control-plane only.
917 */
918 [[nodiscard]] ImageIdentity image_identity(Region range = Region::host()) noexcept;
919
920 /**
921 * @brief Flattens a resolve Result to its address, or a null Address on failure.
922 * @note Callback-safe: a pure Result read.
923 */
924 2 [[nodiscard]] inline Address or_null(const Result<Hit> &result) noexcept
925 {
926
2/2
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 6 taken 1 time.
2 return result ? result->address : Address{};
927 }
928
929 /**
930 * @brief Flattens a resolve Result to its address, or @p fallback on failure.
931 * @note Callback-safe: a pure Result read.
932 */
933 3 [[nodiscard]] inline Address address_or(const Result<Hit> &result, Address fallback = Address{}) noexcept
934 {
935
2/2
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 6 taken 2 times.
3 return result ? result->address : fallback;
936 }
937
938 namespace unchecked
939 {
940 /**
941 * @brief Raw single-pattern scan with no page filter. An unreadable byte in @p region faults the host.
942 * @return The @p occurrence-th (1-based) match, adjusted by the `|` offset. 0, a miss, and a pattern allocation
943 * failure return nullptr.
944 * @note Setup/control-plane only: each call copies @p pattern into two heap buffers, or three with bounded
945 * jumps, and restarts from `region.base`. For a pattern whose `offset()` is zero, a cursor walk passes
946 * occurrence 1 and advances `region.base` past each hit. For a nonzero offset, raise only @p occurrence.
947 * Proof: `ScannerUncheckedAllocationTest.CursorWalkFindsEveryNeedleAtAFixedPerCallCost`.
948 */
949 [[nodiscard]] const std::byte *
950 find_pattern(Region region, const Pattern &pattern, std::size_t occurrence = 1) noexcept;
951 } // namespace unchecked
952
953 } // namespace DetourModKit::scan
954
955 #endif // DETOURMODKIT_SCAN_HPP
956