GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 89.0% 252 / 0 / 283
Functions: 100.0% 18 / 0 / 18
Branches: 62.2% 186 / 0 / 299

src/scanner_cascade.cpp
Line Branch Exec Source
1 /**
2 * @file scanner_cascade.cpp
3 * @brief Multi-candidate cascade resolver with hooked-prologue recovery.
4 *
5 * The higher-level resolution layer over the scan primitives in scanner.cpp:
6 * it tries an ordered list of AOB candidates (whole-process, module-scoped, or host-EXE-scoped), enforces per-candidate
7 * uniqueness, and -- when every direct candidate misses -- rebuilds each Direct candidate's prologue as a near-JMP and
8 * retries to recover a target another mod already inline-hooked. Kept in its own translation unit so the cascade logic
9 * and the SIMD scan engine evolve independently; both share one public header (scanner.hpp) plus the internal
10 * module-scoped scan entry points in scanner_internal.hpp.
11 */
12
13 #include "DetourModKit/scanner.hpp"
14 #include "DetourModKit/memory.hpp"
15 #include "DetourModKit/rtti.hpp"
16 #include "DetourModKit/logger.hpp"
17 #include "DetourModKit/format.hpp"
18 #include "x86_decode.hpp"
19
20 #include "scanner_internal.hpp"
21
22 #include <array>
23 #include <cstddef>
24 #include <cstdint>
25 #include <optional>
26 #include <span>
27 #include <string>
28 #include <string_view>
29 #include <vector>
30
31 namespace DetourModKit
32 {
33 namespace
34 {
35 std::optional<std::uintptr_t>
36 144 resolve_candidate_match(std::uintptr_t match_addr,
37 const DetourModKit::Scanner::AddrCandidate &candidate) noexcept
38 {
39 using DetourModKit::Scanner::ResolveMode;
40
2/2
✓ Branch 2 → 3 taken 142 times.
✓ Branch 2 → 8 taken 2 times.
144 if (candidate.mode == ResolveMode::Direct)
41 {
42 142 const auto resolved = match_addr + static_cast<std::uintptr_t>(candidate.disp_offset);
43 // Apply the same plausibility floor the RipRelative path uses below. A Direct result is normally a real
44 // in-process address, but a pathological disp_offset (a large negative value that underflows the match
45 // address, or a crafted candidate table) can resolve to 0, a low guard-page address, or a kernel-range
46 // / non-canonical value. Reject it here rather than commit to a hit the caller cannot later reverse;
47 // the module-scoped path layers a stricter contains() check on top.
48
2/2
✓ Branch 4 → 5 taken 2 times.
✓ Branch 4 → 6 taken 140 times.
142 if (!DetourModKit::Memory::plausible_userspace_ptr(resolved))
49 {
50 2 return std::nullopt;
51 }
52 140 return resolved;
53 }
54 2 const auto disp_addr = match_addr + static_cast<std::uintptr_t>(candidate.disp_offset);
55 // Fault-guarded read instead of is_readable + raw memcpy: the page can change between the check and the
56 // copy (TOCTOU), so an unguarded memcpy could fault the host process. seh_read returns nullopt on any
57 // fault.
58 2 const auto disp = DetourModKit::Memory::seh_read<std::int32_t>(disp_addr);
59
1/2
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 12 taken 2 times.
2 if (!disp)
60 {
61 // A faulted displacement read is a miss, not a hit at address 0: the whole-process path has no in-range
62 // guard to reject a zero result, so returning nullopt here prevents a false ResolveHit at 0.
63 return std::nullopt;
64 }
65 2 const auto resolved = static_cast<std::uintptr_t>(
66 4 static_cast<std::int64_t>(match_addr + static_cast<std::uintptr_t>(candidate.instr_end_offset)) +
67 2 *disp);
68 // Mirror resolve_rip_relative's plausibility floor. A corrupt or crafted displacement can resolve to 0, a
69 // low guard-page address, or a kernel-range value; the whole-process cascade path has no image to bound the
70 // result (the module-scoped path layers a stricter contains() check on top), so reject an implausible
71 // target here rather than commit to a hit the caller cannot later reverse.
72
1/2
✗ Branch 14 → 15 not taken.
✓ Branch 14 → 16 taken 2 times.
2 if (!DetourModKit::Memory::plausible_userspace_ptr(resolved))
73 {
74 return std::nullopt;
75 }
76 2 return resolved;
77 }
78
79 // Resolves a name/string tier through its backend and lets misses or backend ambiguity fall through to the next
80 // candidate. This helper is intentionally not noexcept: the string-xref path may allocate while building its
81 // scan windows, and throwing out of the byte-mode noexcept helper would terminate the host.
82 13 std::optional<std::uintptr_t> resolve_named_candidate(const DetourModKit::Scanner::AddrCandidate &candidate,
83 DetourModKit::Memory::ModuleRange range)
84 {
85 using DetourModKit::Scanner::ResolveMode;
86
2/2
✓ Branch 2 → 3 taken 5 times.
✓ Branch 2 → 4 taken 8 times.
13 if (candidate.mode == ResolveMode::RttiVtable)
87 {
88 // candidate.pattern is the MSVC mangled type name; the primary (COL.offset == 0) vtable is returned.
89 5 return DetourModKit::Rtti::vtable_for_type(candidate.pattern, range);
90 }
91 // ResolveMode::StringXref: anchor on the immutable literal, then resolve its unique RIP-relative reference.
92 // Build the query with designated initializers, not positional brace-init, so a future StringRefQuery field
93 // reorder cannot silently misassign a facet.
94 const DetourModKit::Scanner::StringRefQuery query{
95 .text = candidate.pattern,
96 8 .encoding = candidate.xref_encoding,
97 8 .require_terminator = candidate.xref_require_terminator,
98 8 .return_mode = candidate.xref_return,
99 8 .broad_match = candidate.xref_broad_match,
100 8 };
101
1/2
✓ Branch 4 → 5 taken 8 times.
✗ Branch 4 → 12 not taken.
8 const auto resolved = DetourModKit::Scanner::find_string_xref(query, range);
102
2/2
✓ Branch 6 → 7 taken 5 times.
✓ Branch 6 → 8 taken 3 times.
8 if (!resolved)
103 {
104 // Any StringXrefError is a fall-through, never a hit at address 0; the ambiguity variants are exactly
105 // the unique-only contract the name/string tiers promise.
106 5 return std::nullopt;
107 }
108 3 return *resolved;
109 }
110
111 // Minimum number of literal (non-wildcard) bytes the tail of the pattern must contain after dropping the first
112 // 5 prologue tokens. Five literal bytes still leave the rebuilt pattern shaped like a generic near-JMP plus a
113 // short common-instruction tail, which collides with thousands of unrelated E9 sites in a multi-megabyte .text
114 // section. Ten literal bytes is roughly two to four real instructions of context and reduces the false-positive
115 // rate to near zero on real binaries while staying inside the 12 to 20 byte sweet spot documented for fallback
116 // signatures.
117 constexpr int PROLOGUE_FALLBACK_MIN_TAIL_LITERALS = 10;
118
119 // Upper bound on hits the rebuilt fallback pattern may produce within the scanned scope (the module image when
120 // a range is supplied, the process's executable regions otherwise) before we reject it as ambiguous. The
121 // fallback only exists to recover the single site where a sibling mod inline-hooked the target function, so the
122 // legitimate rewritten pattern must match exactly once: the unique JMP into that mod's trampoline. Any value
123 // above 1 admits a false positive whose blast radius (a hook installed at an unrelated function) far outweighs
124 // the benefit of tolerating duplicate matches.
125 constexpr std::size_t PROLOGUE_FALLBACK_MAX_HITS = 1;
126
127 // Bytes each recognised inline-hook prologue shape overwrites. E9 rel32 is five bytes (one opcode plus one
128 // int32 displacement); FF 25 disp32 is six (a two-byte opcode plus one int32 RIP-relative displacement). The
129 // 14-byte FF 25 absolute form is the same two-byte opcode and a disp32 of zero, followed by the 8-byte absolute
130 // target inlined immediately after the instruction (RIP then points at it). The `mov rax, imm64; jmp rax`
131 // absolute jump is twelve bytes: REX.W B8 plus an 8-byte immediate, then the two-byte FF E0.
132 constexpr std::size_t PROLOGUE_PATCH_BYTES_E9 = 5;
133 constexpr std::size_t PROLOGUE_PATCH_BYTES_FF25 = 6;
134 constexpr std::size_t PROLOGUE_PATCH_BYTES_FF25_ABS64 = 14;
135 constexpr std::size_t PROLOGUE_PATCH_BYTES_MOV_RAX_JMP = 12;
136
137 /**
138 * @struct PrologueShape
139 * @brief One inline-hook prologue shape the fallback can rebuild and recover.
140 * @details @ref patch_bytes is how many leading prologue bytes the hook overwrites; @ref jump_prefix is the AOB
141 * fragment that replaces them; @ref decode recovers the absolute target the jump redirects to so the
142 * rebuilt match can be gated as a real hook rather than a coincidental opcode collision.
143 */
144 struct PrologueShape
145 {
146 std::size_t patch_bytes = 0;
147 std::string_view jump_prefix;
148 std::optional<std::uintptr_t> (*decode)(std::uintptr_t) noexcept = nullptr;
149 };
150
151 // Inline-hook prologue shapes the fallback tries, in order. E9 is the five-byte near jump SafetyHook / MinHook
152 // emit for a trampoline within rel32 reach; the rest are the far-jump shapes a hook emits when the trampoline
153 // is beyond rel32 reach:
154 // - FF 25 disp32: a six-byte RIP-relative indirect jump whose disp32 points at a separate pointer slot
155 // (a Detours-style far jump) -- decode_ff25_indirect dereferences that slot.
156 // - FF 25 00000000 <abs64>: the fourteen-byte absolute form, a disp32 of zero so the slot is the 8-byte
157 // absolute target inlined right after the instruction. It reuses decode_ff25_indirect, which already
158 // resolves the disp32==0 slot at address+6. A six-byte FF 25 shape cannot recover it: a 14-byte overwrite
159 // leaves a different surviving tail, so the two shapes are disjoint and never alias.
160 // - 48 B8 <imm64> FF E0: the twelve-byte `mov rax, imm64; jmp rax` absolute jump some libraries emit instead
161 // of the FF 25 form -- decode_mov_rax_imm64_jmp_rax returns the inlined imm64 directly (no slot read).
162 // Each shape rebuilds a distinct pattern from the original signature (its own patch_bytes leading drop plus its
163 // jump_prefix), and the prefixes' literal opcode bytes (E9 vs FF 25 vs 48 B8) make the shapes mutually
164 // exclusive at a real hook site, so the try order only affects which is attempted first, never correctness. E9
165 // is first because it is by far the common case; the FF 25 variants precede the rarer mov rax form.
166 constexpr std::array<PrologueShape, 4> PROLOGUE_SHAPES = {{
167 {PROLOGUE_PATCH_BYTES_E9, std::string_view{"E9 ?? ?? ?? ??"}, &DetourModKit::detail::decode_e9_rel32},
168 {PROLOGUE_PATCH_BYTES_FF25, std::string_view{"FF 25 ?? ?? ?? ??"},
169 &DetourModKit::detail::decode_ff25_indirect},
170 {PROLOGUE_PATCH_BYTES_FF25_ABS64, std::string_view{"FF 25 00 00 00 00 ?? ?? ?? ?? ?? ?? ?? ??"},
171 &DetourModKit::detail::decode_ff25_indirect},
172 {PROLOGUE_PATCH_BYTES_MOV_RAX_JMP, std::string_view{"48 B8 ?? ?? ?? ?? ?? ?? ?? ?? FF E0"},
173 &DetourModKit::detail::decode_mov_rax_imm64_jmp_rax},
174 }};
175
176 286 void append_hex_byte(std::string &out, std::byte value)
177 {
178 286 constexpr char digits[] = "0123456789ABCDEF";
179 286 const auto byte_value = std::to_integer<unsigned>(value);
180
1/2
✓ Branch 4 → 5 taken 286 times.
✗ Branch 4 → 7 not taken.
286 out.push_back(digits[(byte_value >> 4) & 0xF]);
181
1/2
✓ Branch 5 → 6 taken 286 times.
✗ Branch 5 → 7 not taken.
286 out.push_back(digits[byte_value & 0xF]);
182 286 }
183
184 // Re-emits one parsed (byte, mask) pair as the AOB token parse_aob would accept: a full literal (mask 0xFF), a
185 // per-nibble token (mask 0xF0 / 0x0F), or a full wildcard (mask 0x00). Keeping the round-trip token-exact
186 // preserves a partially-masked tail byte instead of widening it to a full byte (which would over-constrain) or
187 // to a wildcard.
188 286 void append_pattern_token(std::string &out, std::byte value, std::byte mask)
189 {
190 572 constexpr char digits[] = "0123456789ABCDEF";
191 286 const auto byte_value = std::to_integer<unsigned>(value);
192
1/4
✓ Branch 6 → 7 taken 286 times.
✗ Branch 6 → 9 not taken.
✗ Branch 6 → 12 not taken.
✗ Branch 6 → 15 not taken.
286 switch (std::to_integer<unsigned>(mask))
193 {
194 286 case 0xFF:
195
1/2
✓ Branch 7 → 8 taken 286 times.
✗ Branch 7 → 18 not taken.
286 append_hex_byte(out, value);
196 286 break;
197 case 0xF0:
198 out.push_back(digits[(byte_value >> 4) & 0xF]);
199 out.push_back('?');
200 break;
201 case 0x0F:
202 out.push_back('?');
203 out.push_back(digits[byte_value & 0xF]);
204 break;
205 default:
206 out.append("??");
207 break;
208 }
209 286 }
210
211 // Build from the parsed pattern so parse_aob remains the only token parser. The caller keeps original.offset
212 // and applies it after the fallback scan, which makes `|`-anchored recovery match the direct path exactly.
213 // patch_bytes leading prologue bytes are replaced by jump_prefix; the surviving literal tail is re-emitted
214 // token-exact. Only fully-known tail bytes count toward the literal-tail floor: a partially-masked nibble byte
215 // adds context but not a full byte of false-positive defense, so the conservative count keeps the uniqueness
216 // pre-filter strict.
217 55 std::string build_hooked_prologue_pattern(const DetourModKit::Scanner::CompiledPattern &original,
218 std::size_t patch_bytes, std::string_view jump_prefix)
219 {
220
2/2
✓ Branch 3 → 4 taken 4 times.
✓ Branch 3 → 5 taken 51 times.
55 if (original.size() < patch_bytes)
221 {
222 4 return {};
223 }
224 51 int literal_tail_count = 0;
225
2/2
✓ Branch 11 → 6 taken 386 times.
✓ Branch 11 → 12 taken 51 times.
437 for (std::size_t i = patch_bytes; i < original.size(); ++i)
226 {
227
2/2
✓ Branch 7 → 8 taken 379 times.
✓ Branch 7 → 9 taken 7 times.
386 if (original.mask[i] == std::byte{0xFF})
228 {
229 379 ++literal_tail_count;
230 }
231 }
232
2/2
✓ Branch 12 → 13 taken 26 times.
✓ Branch 12 → 14 taken 25 times.
51 if (literal_tail_count < PROLOGUE_FALLBACK_MIN_TAIL_LITERALS)
233 {
234 26 return {};
235 }
236
1/2
✓ Branch 16 → 17 taken 25 times.
✗ Branch 16 → 30 not taken.
25 std::string out(jump_prefix);
237
2/2
✓ Branch 25 → 19 taken 286 times.
✓ Branch 25 → 26 taken 25 times.
311 for (std::size_t i = patch_bytes; i < original.size(); ++i)
238 {
239
1/2
✓ Branch 19 → 20 taken 286 times.
✗ Branch 19 → 33 not taken.
286 out.push_back(' ');
240
1/2
✓ Branch 22 → 23 taken 286 times.
✗ Branch 22 → 33 not taken.
286 append_pattern_token(out, original.bytes[i], original.mask[i]);
241 }
242 25 return out;
243 25 }
244
245 // Counts up to (max_hits + 1) occurrences of `pattern`. When `range` is set the count is confined to that
246 // module image; otherwise it spans the whole process's executable regions. Returning max_hits+1 signals "too
247 // many to be unique". The count must use the same scope as the eventual match scan, or a pattern unique inside
248 // the target module but duplicated in a sibling overlay would be wrongly rejected (or accepted) on the wrong
249 // evidence. When first_match_out is non-null it receives the first occurrence (the n == 1 scan result, or
250 // nullptr when there were no hits) so a caller that needs both the count and the first match -- the same scan,
251 // same scope -- does not have to sweep again to fetch it.
252 //
253 // When incomplete_out is non-null it receives whether any scan in this count skipped a region that faulted
254 // mid-scan. A faulted region means the returned count is a lower bound: a second occurrence could have lived in
255 // the skipped bytes, so a count of 1 over an incomplete sweep does NOT prove uniqueness. The flag is cleared
256 // here once before the count loop and read after, so the result reflects only the scans this call performed
257 // (not a stale fault from an earlier scan on the same thread). The count itself is unchanged; the caller
258 // decides how to treat the incomplete signal.
259 25 std::size_t count_pattern_hits_bounded(const DetourModKit::Scanner::CompiledPattern &pattern,
260 std::size_t max_hits,
261 std::optional<DetourModKit::Memory::ModuleRange> range,
262 const std::byte **first_match_out = nullptr,
263 bool *incomplete_out = nullptr) noexcept
264 {
265
1/2
✓ Branch 2 → 3 taken 25 times.
✗ Branch 2 → 4 not taken.
25 if (first_match_out != nullptr)
266 {
267 25 *first_match_out = nullptr;
268 }
269 // Clear once before the loop so the flag reflects only the scans below; the region walk OR-sets it on a
270 // faulted skip and never clears it, so a prior scan on this thread must not bleed into this verdict.
271 25 DetourModKit::Scanner::detail::scan_incomplete_flag() = false;
272 25 std::size_t hits = 0;
273
2/2
✓ Branch 18 → 6 taken 39 times.
✓ Branch 18 → 19 taken 3 times.
42 for (std::size_t n = 1; n <= max_hits + 1; ++n)
274 {
275
2/2
✓ Branch 7 → 8 taken 31 times.
✓ Branch 7 → 10 taken 8 times.
39 const auto *match = range ? Scanner::detail::scan_module_executable(pattern, *range, n)
276 8 : DetourModKit::Scanner::scan_executable_regions(pattern, n);
277
2/2
✓ Branch 12 → 13 taken 22 times.
✓ Branch 12 → 14 taken 17 times.
39 if (match == nullptr)
278 {
279 22 break;
280 }
281
3/4
✓ Branch 14 → 15 taken 14 times.
✓ Branch 14 → 17 taken 3 times.
✓ Branch 15 → 16 taken 14 times.
✗ Branch 15 → 17 not taken.
17 if (n == 1 && first_match_out != nullptr)
282 {
283 14 *first_match_out = match;
284 }
285 17 ++hits;
286 }
287
1/2
✓ Branch 19 → 20 taken 25 times.
✗ Branch 19 → 22 not taken.
25 if (incomplete_out != nullptr)
288 {
289 25 *incomplete_out = DetourModKit::Scanner::detail::scan_incomplete_flag();
290 }
291 25 return hits;
292 }
293
294 struct CascadeAttempt
295 {
296 std::uintptr_t address{0};
297 std::size_t index{0};
298 bool success{false};
299 };
300
301 173 CascadeAttempt scan_candidates(std::span<const DetourModKit::Scanner::AddrCandidate> candidates,
302 bool &all_parse_failed, DetourModKit::Scanner::ScannerKind kind,
303 std::optional<DetourModKit::Memory::ModuleRange> range = std::nullopt)
304 {
305 using DetourModKit::Scanner::ResolveMode;
306 173 DetourModKit::Logger &logger = DetourModKit::Logger::get_instance();
307 172 all_parse_failed = true;
308
2/2
✓ Branch 75 → 4 taken 184 times.
✓ Branch 75 → 76 taken 37 times.
221 for (std::size_t i = 0; i < candidates.size(); ++i)
309 {
310 184 const auto &candidate = candidates[i];
311
312 // Name/string tiers branch before parse_aob: their pattern field is a mangled type name or literal,
313 // not byte syntax. They are module-scoped signals, so a range-less cascade uses the host image and an
314 // explicit-range cascade passes that image through. Marking the candidate as valid keeps a
315 // name/string-only miss on the NoMatch path; AllPatternsInvalid is reserved for byte rows whose AOB
316 // text cannot parse.
317
4/4
✓ Branch 5 → 6 taken 180 times.
✓ Branch 5 → 7 taken 5 times.
✓ Branch 6 → 7 taken 8 times.
✓ Branch 6 → 30 taken 172 times.
185 if (candidate.mode == ResolveMode::RttiVtable || candidate.mode == ResolveMode::StringXref)
318 {
319 13 all_parse_failed = false;
320
1/2
✓ Branch 8 → 9 taken 13 times.
✗ Branch 8 → 11 not taken.
13 const auto selected_range = range ? *range : DetourModKit::Memory::host_module_range();
321
1/2
✓ Branch 12 → 13 taken 13 times.
✗ Branch 12 → 78 not taken.
13 const auto resolved = resolve_named_candidate(candidate, selected_range);
322
2/2
✓ Branch 14 → 15 taken 9 times.
✓ Branch 14 → 16 taken 4 times.
13 if (!resolved)
323 {
324 9 continue;
325 }
326 // A module-scoped cascade still bounds the result to its image. Both backends already confine
327 // their search to selected_range, so for the host-module default (range unset) the result is
328 // in-image by construction and this check is skipped; it guards only the explicit-range path,
329 // mirroring the byte-mode contains() gate below.
330
3/6
✓ Branch 17 → 18 taken 4 times.
✗ Branch 17 → 23 not taken.
✗ Branch 21 → 22 not taken.
✓ Branch 21 → 23 taken 4 times.
✗ Branch 24 → 25 not taken.
✓ Branch 24 → 26 taken 4 times.
4 if (range && !DetourModKit::Memory::contains(*range, *resolved))
331 {
332 continue;
333 }
334 4 return CascadeAttempt{*resolved, i, true};
335 }
336
337
1/2
✓ Branch 30 → 31 taken 170 times.
✗ Branch 30 → 85 not taken.
172 auto compiled = DetourModKit::Scanner::parse_aob(candidate.pattern);
338
2/2
✓ Branch 32 → 33 taken 2 times.
✓ Branch 32 → 39 taken 168 times.
170 if (!compiled)
339 {
340
1/2
✓ Branch 37 → 38 taken 2 times.
✗ Branch 37 → 79 not taken.
2 logger.warning("Scanner: Failed to parse AOB for candidate '{}'.",
341
1/2
✗ Branch 34 → 35 not taken.
✓ Branch 34 → 36 taken 2 times.
2 candidate.name.empty() ? std::string_view{"<unnamed>"} : candidate.name);
342 2 continue;
343 }
344 168 all_parse_failed = false;
345
346 // Resolves the Nth occurrence in the active scope. A supplied module range takes precedence over
347 // `kind`: one scan of the contiguous image already covers both .text and .rdata / .data candidates, so
348 // the executable-vs-readable split is moot inside it.
349 301 const auto scan_for = [&](std::size_t occurrence) -> const std::byte *
350 {
351
2/2
✓ Branch 3 → 4 taken 279 times.
✓ Branch 3 → 7 taken 22 times.
301 if (range)
352 {
353 279 return Scanner::detail::scan_module_readable(*compiled, *range, occurrence);
354 }
355 22 return (kind == DetourModKit::Scanner::ScannerKind::Readable)
356
2/2
✓ Branch 7 → 8 taken 3 times.
✓ Branch 7 → 11 taken 19 times.
22 ? DetourModKit::Scanner::scan_readable_regions(*compiled, occurrence)
357 22 : DetourModKit::Scanner::scan_executable_regions(*compiled, occurrence);
358 168 };
359
360
1/2
✓ Branch 39 → 40 taken 170 times.
✗ Branch 39 → 83 not taken.
168 const auto *match = scan_for(1);
361
2/2
✓ Branch 40 → 41 taken 31 times.
✓ Branch 40 → 42 taken 139 times.
170 if (match == nullptr)
362 {
363 31 continue;
364 }
365
366 // Per-candidate uniqueness guard. A candidate flagged require_unique must match exactly once in the
367 // scanned scope; a second occurrence means the pattern is ambiguous, so the first (lowest-address)
368 // match is not provably the intended target. Skip it so the cascade falls through to the next candidate
369 // instead of committing to an arbitrary hit -- the choice between equally-matching sites is semantic
370 // and the scanner cannot make it. The extra occurrence scan only runs for a candidate that already
371 // matched and has require_unique set (the default); set require_unique = false to accept the first
372 // match and skip it.
373
7/8
✓ Branch 42 → 43 taken 133 times.
✓ Branch 42 → 46 taken 6 times.
✓ Branch 43 → 44 taken 133 times.
✗ Branch 43 → 83 not taken.
✓ Branch 44 → 45 taken 3 times.
✓ Branch 44 → 46 taken 130 times.
✓ Branch 47 → 48 taken 3 times.
✓ Branch 47 → 54 taken 136 times.
139 if (candidate.require_unique && scan_for(2) != nullptr)
374 {
375
1/2
✓ Branch 52 → 53 taken 3 times.
✗ Branch 52 → 81 not taken.
3 logger.debug("Scanner: candidate '{}' skipped: matches more than once in "
376 "the scanned scope (require_unique).",
377
1/2
✗ Branch 49 → 50 not taken.
✓ Branch 49 → 51 taken 3 times.
3 candidate.name.empty() ? std::string_view{"<unnamed>"} : candidate.name);
378 3 continue;
379 }
380
381 136 const auto addr = resolve_candidate_match(reinterpret_cast<std::uintptr_t>(match), candidate);
382 // A RipRelative candidate whose displacement read faulted or resolved to an implausible address yields
383 // nothing here (resolve_candidate_match applies the same plausibility floor as resolve_rip_relative),
384 // so the whole-process path cannot commit to a near-null or kernel-range hit. The module-scoped path
385 // below adds a stricter in-image guard.
386
2/2
✓ Branch 56 → 57 taken 2 times.
✓ Branch 56 → 58 taken 134 times.
136 if (!addr)
387 {
388 2 continue;
389 }
390 // Module-scoped resolutions must land inside the image. The match site is already in-range (the
391 // module-scoped scan only searches it), but a RipRelative disp read at an in-module instruction can
392 // still resolve outside the image (e.g. an import thunk in another module). Reject that here so the
393 // cascade falls through to the next candidate instead of committing to an out-of-module address -- a
394 // decision a post-resolution check by the caller could not reverse.
395
6/6
✓ Branch 59 → 60 taken 128 times.
✓ Branch 59 → 65 taken 6 times.
✓ Branch 63 → 64 taken 2 times.
✓ Branch 63 → 65 taken 126 times.
✓ Branch 66 → 67 taken 2 times.
✓ Branch 66 → 68 taken 132 times.
134 if (range && !DetourModKit::Memory::contains(*range, *addr))
396 {
397 2 continue;
398 }
399 132 return CascadeAttempt{*addr, i, true};
400
2/2
✓ Branch 71 → 72 taken 40 times.
✓ Branch 71 → 73 taken 132 times.
172 }
401 37 return CascadeAttempt{0, 0, false};
402 }
403
404 struct PrologueFallbackResult
405 {
406 CascadeAttempt attempt{};
407 // True until some Direct candidate yields a usable rebuilt pattern (enough literal tail to scan).
408 bool not_applicable{true};
409 // True once any Direct candidate parsed -- i.e. the fallback had a real target to rebuild. Lets finalize
410 // tell "Direct present but tail too short" (PrologueFallbackNotApplicable) from "no Direct candidate at
411 // all" (a plain NoMatch); a name/string/RipRelative-only cascade leaves this false.
412 bool had_fallback_candidate{false};
413 };
414
415 // Attempts one prologue shape for a single Direct candidate: rebuilds the patched prologue as shape.jump_prefix
416 // plus the candidate's surviving literal tail, requires that rebuilt pattern to match exactly once in scope,
417 // decodes the jump to recover the redirected target, and gates that destination as a plausible, executable
418 // address. Returns the resolved attempt on success, or nullopt when this shape does not apply or does not
419 // uniquely recover a target. *applicable is set true once the shape produces a usable rebuilt pattern (enough
420 // literal tail), regardless of whether it then matches, so the caller can distinguish "no shape was applicable"
421 // from "applicable but no match".
422 55 std::optional<CascadeAttempt> try_prologue_shape(const DetourModKit::Scanner::AddrCandidate &candidate,
423 std::size_t index,
424 const DetourModKit::Scanner::CompiledPattern &original,
425 std::optional<DetourModKit::Memory::ModuleRange> range,
426 const PrologueShape &shape, bool &applicable)
427 {
428
1/2
✓ Branch 2 → 3 taken 55 times.
✗ Branch 2 → 74 not taken.
55 DetourModKit::Logger &logger = DetourModKit::Logger::get_instance();
429
1/2
✓ Branch 3 → 4 taken 55 times.
✗ Branch 3 → 74 not taken.
55 const std::string hooked = build_hooked_prologue_pattern(original, shape.patch_bytes, shape.jump_prefix);
430
2/2
✓ Branch 5 → 6 taken 30 times.
✓ Branch 5 → 7 taken 25 times.
55 if (hooked.empty())
431 {
432 30 return std::nullopt;
433 }
434
1/2
✓ Branch 8 → 9 taken 25 times.
✗ Branch 8 → 72 not taken.
25 auto compiled = DetourModKit::Scanner::parse_aob(hooked);
435
1/2
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 12 taken 25 times.
25 if (!compiled)
436 {
437 return std::nullopt;
438 }
439 25 applicable = true;
440 25 const std::byte *first_match = nullptr;
441 25 bool count_incomplete = false;
442 25 const std::size_t hits = count_pattern_hits_bounded(*compiled, PROLOGUE_FALLBACK_MAX_HITS, range,
443 25 &first_match, &count_incomplete);
444
2/2
✓ Branch 14 → 15 taken 11 times.
✓ Branch 14 → 16 taken 14 times.
25 if (hits == 0)
445 {
446 11 return std::nullopt;
447 }
448 // Fail closed when the uniqueness count ran over an incomplete sweep. A region that faulted mid-scan
449 // (concurrent decommit / reprotect) was skipped, so a second occurrence of the rebuilt jump pattern could
450 // have lived in the unscanned bytes. The fallback's whole safety rests on the rebuilt pattern matching
451 // exactly once -- the single site a sibling mod inline-hooked -- so an unproven count must be treated as
452 // ambiguous, not as a unique hit. Rejecting here lets the cascade fall through to the next candidate /
453 // shape rather than commit a hook at a possibly-wrong address whose blast radius (an unrelated function)
454 // is severe.
455
1/2
✗ Branch 16 → 17 not taken.
✓ Branch 16 → 23 taken 14 times.
14 if (count_incomplete)
456 {
457 logger.debug("Scanner: prologue fallback rejected for '{}': uniqueness count incomplete (a region "
458 "faulted mid-scan); uniqueness unproven, failing closed",
459 candidate.name.empty() ? std::string_view{"<unnamed>"} : candidate.name);
460 return std::nullopt;
461 }
462
2/2
✓ Branch 23 → 24 taken 3 times.
✓ Branch 23 → 30 taken 11 times.
14 if (hits > PROLOGUE_FALLBACK_MAX_HITS)
463 {
464
1/2
✓ Branch 28 → 29 taken 3 times.
✗ Branch 28 → 63 not taken.
3 logger.debug("Scanner: prologue fallback rejected for '{}': {} hits exceed uniqueness ceiling ({})",
465
1/2
✗ Branch 25 → 26 not taken.
✓ Branch 25 → 27 taken 3 times.
3 candidate.name.empty() ? std::string_view{"<unnamed>"} : candidate.name, hits,
466 PROLOGUE_FALLBACK_MAX_HITS);
467 3 return std::nullopt;
468 }
469 // Reuse the first occurrence the count already located (hits is in [1, PROLOGUE_FALLBACK_MAX_HITS] here, so
470 // first_match is the unique match) instead of re-sweeping the same scope for it.
471 11 const auto match_addr = reinterpret_cast<std::uintptr_t>(first_match);
472 11 const auto decoded = shape.decode(match_addr);
473
1/2
✗ Branch 32 → 33 not taken.
✓ Branch 32 → 34 taken 11 times.
11 if (!decoded)
474 {
475 return std::nullopt;
476 }
477 11 const auto jmp_destination = *decoded;
478 // The rewritten jump was FOUND inside the target (the module image when `range` is set, the process
479 // otherwise), but its destination must NOT be constrained to that module. When a sibling mod inline-hooks
480 // the target, its jump can land on a trampoline the sibling allocated outside every loaded module;
481 // SafetyHook does this for inline hooks, and an FF 25 far jump stores an absolute trampoline address in its
482 // slot. An in-module requirement would reject the very recovery this path exists for. Gate the destination
483 // on "plausible user-space pointer on a committed, execute-readable page" instead: that still rejects a
484 // jump into unmapped or data-only memory (a coincidental opcode match whose tail happened to align), while
485 // the uniqueness ceiling and the literal-tail floor remain the primary false-positive defense.
486
3/4
✓ Branch 36 → 37 taken 11 times.
✗ Branch 36 → 39 not taken.
✓ Branch 41 → 42 taken 3 times.
✓ Branch 41 → 50 taken 8 times.
22 if (!DetourModKit::Memory::plausible_userspace_ptr(jmp_destination) ||
487
2/2
✓ Branch 38 → 39 taken 3 times.
✓ Branch 38 → 40 taken 8 times.
11 !Scanner::detail::is_executable_address(jmp_destination))
488 {
489
1/2
✓ Branch 47 → 48 taken 3 times.
✗ Branch 47 → 65 not taken.
3 logger.debug("Scanner: prologue fallback rejected for '{}': jump destination {} is not executable",
490
1/2
✗ Branch 44 → 45 not taken.
✓ Branch 44 → 46 taken 3 times.
3 candidate.name.empty() ? std::string_view{"<unnamed>"} : candidate.name,
491
1/2
✓ Branch 42 → 43 taken 3 times.
✗ Branch 42 → 69 not taken.
6 Format::format_address(jmp_destination));
492 3 return std::nullopt;
493 }
494
495 // The rebuilt pattern compiled with offset 0: replacing the prologue with the jump prefix dropped the
496 // original
497 // `|` anchor, so its scan returned the bare match start. Reconstruct the anchored match the direct pass
498 // would have produced (match start plus the original anchor offset) before resolving, so a `|`-anchored
499 // Direct candidate recovered here lands on the same byte as the unhooked direct scan instead of short by
500 // the offset.
501 8 const auto anchored_match = match_addr + static_cast<std::uintptr_t>(original.offset);
502 8 const auto addr = resolve_candidate_match(anchored_match, candidate);
503
1/2
✗ Branch 52 → 53 not taken.
✓ Branch 52 → 54 taken 8 times.
8 if (!addr)
504 {
505 return std::nullopt;
506 }
507 8 return CascadeAttempt{*addr, index, true};
508 55 }
509
510 PrologueFallbackResult
511 19 scan_candidates_hooked_prologue(std::span<const DetourModKit::Scanner::AddrCandidate> candidates,
512 std::optional<DetourModKit::Memory::ModuleRange> range = std::nullopt)
513 {
514 using DetourModKit::Scanner::ResolveMode;
515 19 DetourModKit::Logger &logger = DetourModKit::Logger::get_instance();
516 19 PrologueFallbackResult out;
517
2/2
✓ Branch 38 → 4 taken 22 times.
✓ Branch 38 → 39 taken 11 times.
33 for (std::size_t i = 0; i < candidates.size(); ++i)
518 {
519 22 const auto &candidate = candidates[i];
520
2/2
✓ Branch 5 → 6 taken 4 times.
✓ Branch 5 → 7 taken 18 times.
22 if (candidate.mode != ResolveMode::Direct)
521 {
522 4 continue;
523 }
524 // Parse the original signature with the same parser the direct pass uses, so the fallback inherits its
525 // token validation and -- critically -- its `|` anchor offset, carried into the resolve below. A
526 // malformed pattern was already reported by the direct pass, so skip it silently here.
527
1/2
✓ Branch 7 → 8 taken 18 times.
✗ Branch 7 → 46 not taken.
18 const auto original = DetourModKit::Scanner::parse_aob(candidate.pattern);
528
1/2
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 18 times.
18 if (!original)
529 {
530 continue;
531 }
532 // A Direct candidate that parses is a real fallback target, even if its literal tail later proves too
533 // short to rebuild. Recording that lets finalize distinguish "tail too short"
534 // (PrologueFallbackNotApplicable) from "no Direct candidate at all" (a plain NoMatch).
535 18 out.had_fallback_candidate = true;
536 // Try each recognised inline-hook prologue shape in turn. The first that uniquely recovers an
537 // executable target wins; E9 is tried before FF 25 because it is by far the common case.
538 18 bool any_applicable = false;
539
2/2
✓ Branch 21 → 12 taken 55 times.
✓ Branch 21 → 22 taken 10 times.
65 for (const PrologueShape &shape : PROLOGUE_SHAPES)
540 {
541 55 bool applicable = false;
542
1/2
✓ Branch 13 → 14 taken 55 times.
✗ Branch 13 → 41 not taken.
55 const auto attempt = try_prologue_shape(candidate, i, *original, range, shape, applicable);
543
2/2
✓ Branch 14 → 15 taken 25 times.
✓ Branch 14 → 16 taken 30 times.
55 if (applicable)
544 {
545 25 any_applicable = true;
546 25 out.not_applicable = false;
547 }
548
2/2
✓ Branch 17 → 18 taken 8 times.
✓ Branch 17 → 20 taken 47 times.
55 if (attempt)
549 {
550 8 out.attempt = *attempt;
551 8 return out;
552 }
553 }
554
2/2
✓ Branch 22 → 23 taken 3 times.
✓ Branch 22 → 29 taken 7 times.
10 if (!any_applicable)
555 {
556
1/2
✓ Branch 27 → 28 taken 3 times.
✗ Branch 27 → 42 not taken.
3 logger.debug("Scanner: prologue fallback skipped for '{}' (insufficient literal tail bytes)",
557
1/2
✗ Branch 24 → 25 not taken.
✓ Branch 24 → 26 taken 3 times.
6 candidate.name.empty() ? std::string_view{"<unnamed>"} : candidate.name);
558 }
559
2/3
✓ Branch 31 → 32 taken 10 times.
✗ Branch 31 → 34 not taken.
✓ Branch 31 → 35 taken 8 times.
18 }
560 11 return out;
561 }
562
563 // Maps a finished non-fallback cascade attempt to the public result, emitting the single success debug line or
564 // the matching failure diagnostic. Shared by the whole-process and module-scoped resolvers so the three-way
565 // mapping (success / AllPatternsInvalid / NoMatch) stays identical across both.
566 std::expected<DetourModKit::Scanner::ResolveHit, DetourModKit::Scanner::ResolveError>
567 151 finalize_cascade(const CascadeAttempt &attempt, bool all_parse_failed,
568 std::span<const DetourModKit::Scanner::AddrCandidate> candidates, std::string_view label)
569 {
570 using DetourModKit::Scanner::ResolveError;
571 using DetourModKit::Scanner::ResolveHit;
572 151 DetourModKit::Logger &logger = DetourModKit::Logger::get_instance();
573
2/2
✓ Branch 3 → 4 taken 133 times.
✓ Branch 3 → 14 taken 18 times.
151 if (attempt.success)
574 {
575 133 const auto &winner = candidates[attempt.index];
576
1/2
✓ Branch 10 → 11 taken 133 times.
✗ Branch 10 → 24 not taken.
132 logger.debug("{} resolved via '{}' at {}", label,
577
1/2
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 132 times.
133 winner.name.empty() ? std::string_view{"<unnamed>"} : winner.name,
578
1/2
✓ Branch 5 → 6 taken 133 times.
✗ Branch 5 → 28 not taken.
266 Format::format_address(attempt.address));
579 133 return ResolveHit{attempt.address, winner.name};
580 }
581
2/2
✓ Branch 14 → 15 taken 2 times.
✓ Branch 14 → 19 taken 16 times.
18 if (all_parse_failed)
582 {
583
1/2
✓ Branch 15 → 16 taken 2 times.
✗ Branch 15 → 29 not taken.
2 logger.error("{}: every byte candidate pattern failed to parse.", label);
584 2 return std::unexpected(ResolveError::AllPatternsInvalid);
585 }
586
1/2
✓ Branch 19 → 20 taken 16 times.
✗ Branch 19 → 30 not taken.
16 logger.warning("{}: cascade resolve failed (no candidate matched).", label);
587 16 return std::unexpected(ResolveError::NoMatch);
588 }
589
590 // Maps a finished cascade + prologue-fallback attempt to the public result. Mirrors finalize_cascade but adds
591 // the pre-hooked-prologue success line and the fallback-specific failure diagnostics (not-applicable vs
592 // no-match).
593 std::expected<DetourModKit::Scanner::ResolveHit, DetourModKit::Scanner::ResolveError>
594 22 finalize_cascade_with_fallback(const CascadeAttempt &attempt, bool all_parse_failed,
595 const PrologueFallbackResult &hooked,
596 std::span<const DetourModKit::Scanner::AddrCandidate> candidates,
597 std::string_view label)
598 {
599 using DetourModKit::Scanner::ResolveError;
600 using DetourModKit::Scanner::ResolveHit;
601 22 DetourModKit::Logger &logger = DetourModKit::Logger::get_instance();
602
2/2
✓ Branch 3 → 4 taken 3 times.
✓ Branch 3 → 14 taken 19 times.
22 if (attempt.success)
603 {
604 3 const auto &winner = candidates[attempt.index];
605
1/2
✓ Branch 10 → 11 taken 3 times.
✗ Branch 10 → 41 not taken.
3 logger.debug("{} resolved via '{}' at {}", label,
606
1/2
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 3 times.
3 winner.name.empty() ? std::string_view{"<unnamed>"} : winner.name,
607
1/2
✓ Branch 5 → 6 taken 3 times.
✗ Branch 5 → 45 not taken.
6 Format::format_address(attempt.address));
608 3 return ResolveHit{attempt.address, winner.name};
609 }
610
2/2
✓ Branch 14 → 15 taken 8 times.
✓ Branch 14 → 25 taken 11 times.
19 if (hooked.attempt.success)
611 {
612 8 const auto &winner = candidates[hooked.attempt.index];
613
1/2
✓ Branch 21 → 22 taken 8 times.
✗ Branch 21 → 46 not taken.
8 logger.debug("{} resolved via '{}' at {} (pre-hooked prologue; reusing target)", label,
614
1/2
✗ Branch 18 → 19 not taken.
✓ Branch 18 → 20 taken 8 times.
8 winner.name.empty() ? std::string_view{"<unnamed>"} : winner.name,
615
1/2
✓ Branch 16 → 17 taken 8 times.
✗ Branch 16 → 50 not taken.
16 Format::format_address(hooked.attempt.address));
616 8 return ResolveHit{hooked.attempt.address, winner.name};
617 }
618
1/2
✗ Branch 25 → 26 not taken.
✓ Branch 25 → 30 taken 11 times.
11 if (all_parse_failed)
619 {
620 logger.error("{}: every byte candidate pattern failed to parse.", label);
621 return std::unexpected(ResolveError::AllPatternsInvalid);
622 }
623 // PrologueFallbackNotApplicable means a Direct candidate was present to rebuild but its literal tail was
624 // too short -- not "no fallback ran". A cascade of only name/string/RipRelative tiers has no Direct row to
625 // rebuild, so its full miss is a plain NoMatch, not this diagnostic.
626
4/4
✓ Branch 30 → 31 taken 4 times.
✓ Branch 30 → 36 taken 7 times.
✓ Branch 31 → 32 taken 3 times.
✓ Branch 31 → 36 taken 1 time.
11 if (hooked.not_applicable && hooked.had_fallback_candidate)
627 {
628
1/2
✓ Branch 32 → 33 taken 3 times.
✗ Branch 32 → 52 not taken.
3 logger.warning(
629 "{}: cascade resolve failed; prologue fallback not applicable (insufficient literal tail bytes).",
630 label);
631 3 return std::unexpected(ResolveError::PrologueFallbackNotApplicable);
632 }
633
1/2
✓ Branch 36 → 37 taken 8 times.
✗ Branch 36 → 53 not taken.
8 logger.warning("{}: cascade resolve failed (including prologue fallback).", label);
634 8 return std::unexpected(ResolveError::NoMatch);
635 }
636 } // anonymous namespace
637
638 std::expected<DetourModKit::Scanner::ResolveHit, DetourModKit::Scanner::ResolveError>
639 13 DetourModKit::Scanner::resolve_cascade(std::span<const AddrCandidate> candidates, std::string_view label,
640 ScannerKind kind)
641 {
642
1/2
✓ Branch 2 → 3 taken 13 times.
✗ Branch 2 → 17 not taken.
13 auto &logger = Logger::get_instance();
643
644
2/2
✓ Branch 4 → 5 taken 2 times.
✓ Branch 4 → 9 taken 11 times.
13 if (candidates.empty())
645 {
646
1/2
✓ Branch 5 → 6 taken 2 times.
✗ Branch 5 → 15 not taken.
2 logger.warning("Scanner: resolve_cascade for '{}' called with no candidates.", label);
647 2 return std::unexpected(ResolveError::EmptyCandidates);
648 }
649
650 11 bool all_parse_failed = true;
651
1/2
✓ Branch 10 → 11 taken 11 times.
✗ Branch 10 → 16 not taken.
11 const auto attempt = scan_candidates(candidates, all_parse_failed, kind);
652
1/2
✓ Branch 11 → 12 taken 11 times.
✗ Branch 11 → 17 not taken.
11 return finalize_cascade(attempt, all_parse_failed, candidates, label);
653 }
654
655 std::expected<DetourModKit::Scanner::ResolveHit, DetourModKit::Scanner::ResolveError>
656 146 DetourModKit::Scanner::resolve_cascade_in_module(std::span<const AddrCandidate> candidates, std::string_view label,
657 Memory::ModuleRange range)
658 {
659
1/2
✓ Branch 2 → 3 taken 146 times.
✗ Branch 2 → 24 not taken.
146 auto &logger = Logger::get_instance();
660
661
2/2
✓ Branch 4 → 5 taken 3 times.
✓ Branch 4 → 9 taken 143 times.
146 if (candidates.empty())
662 {
663
1/2
✓ Branch 5 → 6 taken 3 times.
✗ Branch 5 → 21 not taken.
3 logger.warning("Scanner: resolve_cascade_in_module for '{}' called with no candidates.", label);
664 3 return std::unexpected(ResolveError::EmptyCandidates);
665 }
666
2/2
✓ Branch 10 → 11 taken 3 times.
✓ Branch 10 → 15 taken 140 times.
143 if (!range.valid())
667 {
668
1/2
✓ Branch 11 → 12 taken 3 times.
✗ Branch 11 → 22 not taken.
3 logger.warning("Scanner: resolve_cascade_in_module for '{}' called with an invalid module range.", label);
669 3 return std::unexpected(ResolveError::InvalidRange);
670 }
671
672 140 bool all_parse_failed = true;
673 // The ScannerKind argument is unused once a range is supplied (see
674 // scan_candidates): one module-scoped scan covers .text and .rdata together.
675
1/2
✓ Branch 16 → 17 taken 140 times.
✗ Branch 16 → 23 not taken.
140 const auto attempt = scan_candidates(candidates, all_parse_failed, ScannerKind::Executable, range);
676
1/2
✓ Branch 17 → 18 taken 140 times.
✗ Branch 17 → 24 not taken.
140 return finalize_cascade(attempt, all_parse_failed, candidates, label);
677 }
678
679 std::expected<DetourModKit::Scanner::ResolveHit, DetourModKit::Scanner::ResolveError>
680 6 DetourModKit::Scanner::resolve_cascade_with_prologue_fallback(std::span<const AddrCandidate> candidates,
681 std::string_view label)
682 {
683
1/2
✓ Branch 2 → 3 taken 6 times.
✗ Branch 2 → 23 not taken.
6 auto &logger = Logger::get_instance();
684
685
1/2
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 9 taken 6 times.
6 if (candidates.empty())
686 {
687 logger.warning("Scanner: resolve_cascade_with_prologue_fallback for '{}' called with no candidates.",
688 label);
689 return std::unexpected(ResolveError::EmptyCandidates);
690 }
691
692 // Prologue recovery is a code-shape heuristic (it rebuilds a hooked
693 // near-JMP prologue), so this resolver is executable-only by construction;
694 // the readable sweep is meaningless for it.
695 6 bool all_parse_failed = true;
696
1/2
✓ Branch 10 → 11 taken 6 times.
✗ Branch 10 → 20 not taken.
6 const auto attempt = scan_candidates(candidates, all_parse_failed, ScannerKind::Executable);
697 // The prologue-recovery pass is expensive (it rescans the process), so run it only when the direct pass found
698 // nothing.
699 6 PrologueFallbackResult hooked;
700
1/2
✓ Branch 11 → 12 taken 6 times.
✗ Branch 11 → 15 not taken.
6 if (!attempt.success)
701 {
702
1/2
✓ Branch 13 → 14 taken 6 times.
✗ Branch 13 → 21 not taken.
6 hooked = scan_candidates_hooked_prologue(candidates);
703 }
704
1/2
✓ Branch 15 → 16 taken 6 times.
✗ Branch 15 → 23 not taken.
6 return finalize_cascade_with_fallback(attempt, all_parse_failed, hooked, candidates, label);
705 }
706
707 std::expected<DetourModKit::Scanner::ResolveHit, DetourModKit::Scanner::ResolveError>
708 17 DetourModKit::Scanner::resolve_cascade_in_module_with_prologue_fallback(std::span<const AddrCandidate> candidates,
709 std::string_view label,
710 Memory::ModuleRange range)
711 {
712
1/2
✓ Branch 2 → 3 taken 17 times.
✗ Branch 2 → 30 not taken.
17 auto &logger = Logger::get_instance();
713
714
2/2
✓ Branch 4 → 5 taken 1 time.
✓ Branch 4 → 9 taken 16 times.
17 if (candidates.empty())
715 {
716
1/2
✓ Branch 5 → 6 taken 1 time.
✗ Branch 5 → 25 not taken.
1 logger.warning(
717 "Scanner: resolve_cascade_in_module_with_prologue_fallback for '{}' called with no candidates.", label);
718 1 return std::unexpected(ResolveError::EmptyCandidates);
719 }
720
1/2
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 15 taken 16 times.
16 if (!range.valid())
721 {
722 logger.warning("Scanner: resolve_cascade_in_module_with_prologue_fallback for '{}' called with an invalid "
723 "module range.",
724 label);
725 return std::unexpected(ResolveError::InvalidRange);
726 }
727
728 // Both the direct pass and the prologue-recovery pass confine their scans to the module image: the match site
729 // must be in-range, while a rebuilt near-JMP may still jump to a trampoline outside it (see
730 // scan_candidates_hooked_prologue).
731 16 bool all_parse_failed = true;
732
1/2
✓ Branch 16 → 17 taken 16 times.
✗ Branch 16 → 27 not taken.
16 const auto attempt = scan_candidates(candidates, all_parse_failed, ScannerKind::Executable, range);
733 16 PrologueFallbackResult hooked;
734
2/2
✓ Branch 17 → 18 taken 13 times.
✓ Branch 17 → 21 taken 3 times.
16 if (!attempt.success)
735 {
736
1/2
✓ Branch 19 → 20 taken 13 times.
✗ Branch 19 → 28 not taken.
13 hooked = scan_candidates_hooked_prologue(candidates, range);
737 }
738
1/2
✓ Branch 21 → 22 taken 16 times.
✗ Branch 21 → 30 not taken.
16 return finalize_cascade_with_fallback(attempt, all_parse_failed, hooked, candidates, label);
739 }
740
741 std::expected<DetourModKit::Scanner::ResolveHit, DetourModKit::Scanner::ResolveError>
742 3 DetourModKit::Scanner::resolve_cascade_in_host_module(std::span<const AddrCandidate> candidates,
743 std::string_view label)
744 {
745 // host_module_range() returns an invalid range (base == end == 0) when the main module cannot be resolved;
746 // forward the typed failure rather than letting the underlying resolver scan an empty span and report NoMatch.
747 3 const auto range = Memory::host_module_range();
748
1/2
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 10 taken 3 times.
3 if (!range.valid())
749 {
750 Logger::get_instance().warning(
751 "Scanner: resolve_cascade_in_host_module for '{}' could not determine the host module range.", label);
752 return std::unexpected(ResolveError::InvalidRange);
753 }
754
1/2
✓ Branch 10 → 11 taken 3 times.
✗ Branch 10 → 15 not taken.
3 return resolve_cascade_in_module(candidates, label, range);
755 }
756
757 std::expected<DetourModKit::Scanner::ResolveHit, DetourModKit::Scanner::ResolveError>
758 2 DetourModKit::Scanner::resolve_cascade_in_host_module_with_prologue_fallback(
759 std::span<const AddrCandidate> candidates, std::string_view label)
760 {
761 2 const auto range = Memory::host_module_range();
762
1/2
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 10 taken 2 times.
2 if (!range.valid())
763 {
764 Logger::get_instance().warning(
765 "Scanner: resolve_cascade_in_host_module_with_prologue_fallback for '{}' could not "
766 "determine the host module range.",
767 label);
768 return std::unexpected(ResolveError::InvalidRange);
769 }
770
1/2
✓ Branch 10 → 11 taken 2 times.
✗ Branch 10 → 15 not taken.
2 return resolve_cascade_in_module_with_prologue_fallback(candidates, label, range);
771 }
772 } // namespace DetourModKit
773