GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 90.0% 515 / 0 / 572
Functions: 100.0% 35 / 0 / 35
Branches: 75.9% 369 / 0 / 486

src/rtti.cpp
Line Branch Exec Source
1 /**
2 * @file rtti.cpp
3 * @brief Implementation of MSVC RTTI introspection primitives.
4 *
5 * Walks RTTICompleteObjectLocator -> TypeDescriptor -> mangled name through SEH-guarded reads. The COL layout is read
6 * in a single batch to minimise the number of guarded-read transitions; on MSVC each __try frame is essentially free,
7 * while MinGW enters the VEH fault-containment path for each transition.
8 *
9 * Every address derived from a COL field is bound-checked against the vtable's owning module range before being
10 * dereferenced. This guarantees that a forged or corrupted COL cannot redirect the walker to read from another loaded
11 * module or from an unrelated mapped region.
12 *
13 * The verified prelude (resolve_col_site) and the page-bounded name copy (read_name_seh) live in the internal
14 * rtti_shared.hpp header so the reverse dissector in rtti_dissect.cpp reuses them byte-for-byte rather than
15 * duplicating the walk.
16 *
17 * The public surface uses Address; the raw ABI sweepers below stay on std::uintptr_t because they do qword-granular
18 * pointer arithmetic over image memory, and the Address <-> integer punning is confined to the boundary conversions at
19 * each public entry point.
20 */
21
22 #include "DetourModKit/rtti.hpp"
23 #include "DetourModKit/memory.hpp"
24
25 #include "internal/image_identity.hpp"
26 #include "internal/memory_guarded.hpp"
27 #include "internal/memory_representation_win32.hpp"
28 #include "internal/rtti_shared.hpp"
29
30 #include <windows.h>
31
32 #include <cstdint>
33 #include <cstring>
34
35 namespace DetourModKit::detail
36 {
37 template <> struct enable_representation_safe_aggregate<rtti::detail::ColHead> : std::true_type
38 {
39 };
40 } // namespace DetourModKit::detail
41
42 namespace DetourModKit::detail
43 {
44 #if defined(DMK_ENABLE_TEST_SEAMS)
45 // Test-only override for the monotonic millisecond clock TypeIdentity's unresolved re-sweep throttle reads. Null in
46 // test builds unless a fixture installs a controllable source.
47 std::uint64_t (*g_rtti_resolve_clock_override)() noexcept = nullptr;
48
49 // Test-only controls for mapping-generation and module-extent transitions.
50 std::uint64_t (*g_rtti_image_generation_override)(std::uintptr_t address) noexcept = nullptr;
51 Region (*g_rtti_module_region_override)(Address address) noexcept = nullptr;
52 #endif
53 } // namespace DetourModKit::detail
54
55 namespace DetourModKit
56 {
57 namespace
58 {
59 // Monotonic millisecond clock for the TypeIdentity re-sweep throttle. GetTickCount64 reads KUSER_SHARED_DATA
60 // (no syscall), so gating the per-frame miss path on it is essentially free. Routed through the test override
61 // so the suite can advance time deterministically without a real sleep.
62 26 [[nodiscard]] std::uint64_t rtti_now_ms() noexcept
63 {
64 #if defined(DMK_ENABLE_TEST_SEAMS)
65
2/2
✓ Branch 2 → 3 taken 11 times.
✓ Branch 2 → 4 taken 15 times.
26 if (auto *override_fn = DetourModKit::detail::g_rtti_resolve_clock_override)
66 {
67 11 return override_fn();
68 }
69 #endif
70 15 return ::GetTickCount64();
71 }
72
73 // After an unresolved TypeIdentity miss, skip re-sweeping the whole module until this many milliseconds have
74 // elapsed. A miss is never latched permanently (the owning module may map the type later, when a DLL loads or
75 // a patch finishes relocating the vtable), so without a throttle a per-frame identity check for an absent type
76 // would re-sweep the entire module every frame, the one genuine per-frame cliff. 250 ms bounds that to at most
77 // ~4 module sweeps per second while keeping the eventual resolve latency sub-second once the type appears.
78 constexpr std::uint64_t RESOLVE_RETRY_COOLDOWN_MS = 250;
79
80 // Complete < Incomplete < Saturated, so the maximum retains the strongest reason a verdict is not
81 // authoritative.
82 12 [[nodiscard]] rtti::Traversal merge_traversal(rtti::Traversal a, rtti::Traversal b) noexcept
83 {
84
1/2
✓ Branch 2 → 3 taken 12 times.
✗ Branch 2 → 4 not taken.
12 return (static_cast<std::uint8_t>(a) < static_cast<std::uint8_t>(b)) ? b : a;
85 }
86
87 [[nodiscard]] Region current_module_region(Address address) noexcept;
88 [[nodiscard]] std::uint64_t current_image_stamp(std::uintptr_t module_base) noexcept;
89 } // namespace
90
91 1249142 bool rtti::detail::resolve_col_site(
92 std::uintptr_t vtable,
93 const DetourModKit::detail::ModuleSpan &mod_range,
94 ColSite &out
95 ) noexcept
96 {
97
1/2
✗ Branch 2 → 3 not taken.
✓ Branch 2 → 4 taken 1249142 times.
1249142 if (vtable < MIN_VALID_PTR)
98 return false;
99
100 // The caller supplies the vtable's owning-module span, so this overload skips the per-candidate
101 // memory::module_of loader lookup. Require the vtable to lie inside the supplied span so every bound check
102 // below anchors on the module that actually owns it - the invariant the module_of-resolving overload gets
103 // implicitly (the loader only resolves a module that contains the address). A candidate one past the module
104 // end fails closed here rather than validating against a foreign module.
105
5/6
✓ Branch 5 → 6 taken 1249131 times.
✓ Branch 5 → 8 taken 11 times.
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 1249131 times.
✓ Branch 10 → 11 taken 11 times.
✓ Branch 10 → 12 taken 1249131 times.
1249142 if (!mod_range.valid() || !mod_range.contains(vtable))
106 11 return false;
107
108 // vtable[-1] holds a pointer to the RTTICompleteObjectLocator. The COL must live in the same module as the
109 // vtable; a COL pointer escaping the module range is the signature of a forged or relocated structure and
110 // aborts the walk.
111 1249131 const auto col_ptr_opt = DetourModKit::detail::guarded_read<std::uintptr_t>(
112 1249131 static_cast<std::uintptr_t>(vtable + COL_OFFSET_FROM_VTABLE)
113 );
114
3/6
✓ Branch 14 → 15 taken 1249131 times.
✗ Branch 14 → 18 not taken.
✗ Branch 17 → 18 not taken.
✓ Branch 17 → 19 taken 1249131 times.
✗ Branch 20 → 21 not taken.
✓ Branch 20 → 22 taken 1249131 times.
1249131 if (!col_ptr_opt || !mod_range.contains(*col_ptr_opt))
115 return false;
116 1249131 const std::uintptr_t col_addr = *col_ptr_opt;
117
118 // contains() above proved col_addr is in [base, end), but the guarded_read<ColHead> below pulls sizeof(ColHead)
119 // bytes, which a COL sitting within that many bytes of the module end would straddle past. The SEH guard
120 // faults cleanly on an unmapped straddle, but reject the whole-span overrun up front so the walk never reads
121 // COL fields out of an adjacent mapped image. (mod_range.end - col_addr cannot underflow: col_addr < end.)
122
1/2
✗ Branch 23 → 24 not taken.
✓ Branch 23 → 25 taken 1249131 times.
1249131 if (mod_range.end - col_addr < sizeof(ColHead))
123 return false;
124
125 // Batched read: pulling all six COL fields through one guard matters on MinGW, where each transition enters
126 // the VEH and TLS fault-containment path.
127 1249131 const auto head_opt = DetourModKit::detail::guarded_read<ColHead>(col_addr);
128
5/6
✓ Branch 27 → 28 taken 1249131 times.
✗ Branch 27 → 30 not taken.
✓ Branch 29 → 30 taken 360969 times.
✓ Branch 29 → 31 taken 888162 times.
✓ Branch 32 → 33 taken 360969 times.
✓ Branch 32 → 34 taken 888162 times.
1249131 if (!head_opt || head_opt->p_type_descriptor == 0)
129 360969 return false;
130
131 // Scope is x64 MSVC, where the COL signature is always 1 and carries pSelf, an RVA back to the COL itself.
132 // Reject any other signature outright: a non-x64 or corrupt signature has no pSelf to cross-check, so accepting
133 // it would skip the forgery guard below and fall through to the loader base unverified. The canonical
134 // IDA/Ghidra technique computes the image base as col_addr - p_self; cross-check that recovered base against
135 // the loader-reported module base so a forged p_self cannot bend the walk to another module.
136
2/2
✓ Branch 35 → 36 taken 887820 times.
✓ Branch 35 → 37 taken 342 times.
888162 if (head_opt->signature != COL_SIGNATURE_X64)
137 887820 return false;
138
3/6
✓ Branch 38 → 39 taken 342 times.
✗ Branch 38 → 41 not taken.
✗ Branch 40 → 41 not taken.
✓ Branch 40 → 42 taken 342 times.
✗ Branch 43 → 44 not taken.
✓ Branch 43 → 45 taken 342 times.
342 if (head_opt->p_self == 0 || col_addr < head_opt->p_self)
139 return false;
140
2/2
✓ Branch 46 → 47 taken 3 times.
✓ Branch 46 → 48 taken 339 times.
342 if (col_addr - head_opt->p_self != mod_range.base)
141 3 return false;
142
143 // Compute the TypeDescriptor and name-buffer addresses from the module base plus the type-descriptor RVA. A
144 // bogus RVA that places the name buffer outside the module range (or wraps the address space) is rejected by
145 // the in-module bound check, which also requires the range to be valid. The name address is the strictly-higher
146 // of the two (td + 0x10), so bound-checking it implies td_addr is in range as well.
147 339 const std::uintptr_t td_addr = mod_range.base + head_opt->p_type_descriptor;
148 339 const std::uintptr_t name_addr = td_addr + TD_NAME_OFFSET;
149
2/2
✓ Branch 50 → 51 taken 4 times.
✓ Branch 50 → 52 taken 335 times.
339 if (!mod_range.contains(name_addr))
150 4 return false;
151
152 335 out.col_addr = col_addr;
153 335 out.td_addr = td_addr;
154 335 out.name_addr = name_addr;
155 // Carry the owning module's end so read_name_seh can clamp the name copy to this module: name_addr is proven
156 // below module_end (contains() above), but the string past it is not, so the read must stop at the boundary.
157 335 out.module_end = mod_range.end;
158 335 out.col_offset = head_opt->offset;
159 335 return true;
160 }
161
162 101 bool rtti::detail::resolve_col_site(std::uintptr_t vtable, ColSite &out) noexcept
163 {
164 // Reject obviously-invalid pointers before the loader lookup so a heap or tiny address never pays for a
165 // GetModuleHandleExW that would only fail. A valid vtable resolves its owning module, then the shared walk runs
166 // through the span overload; an unmapped or unowned address yields an invalid span the overload rejects.
167
2/2
✓ Branch 2 → 3 taken 6 times.
✓ Branch 2 → 4 taken 95 times.
101 if (vtable < MIN_VALID_PTR)
168 6 return false;
169 const DetourModKit::detail::ModuleSpan mod_range =
170 95 DetourModKit::detail::module_span(DetourModKit::detail::live_module_region(Address{vtable}));
171 95 return resolve_col_site(vtable, mod_range, out);
172 }
173
174 std::size_t
175 125 rtti::detail::read_name_seh(std::uintptr_t addr, char *out, std::size_t out_len, std::uintptr_t module_end) noexcept
176 {
177
2/4
✓ Branch 2 → 3 taken 125 times.
✗ Branch 2 → 4 not taken.
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 125 times.
125 if (!out || out_len == 0)
178 return 0;
179 125 out[0] = '\0';
180
1/2
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 125 times.
125 if (addr < MIN_VALID_PTR)
181 return 0;
182
183 125 const std::size_t max_chars = out_len - 1;
184
185 // The temporary is capped at rtti::MAX_TYPE_NAME_LEN, which is the documented hard upper bound for any single
186 // name read. Names produced by MSVC RTTI in practice never approach this bound, so the cap costs nothing.
187 char tmp[MAX_TYPE_NAME_LEN];
188 125 std::size_t accum_cap = (max_chars < sizeof(tmp)) ? max_chars : sizeof(tmp);
189
190 // Clamp the accumulation to the owning module's end. resolve_col_site proves name_addr < module_end, so the
191 // in-module span is at least one byte; capping accum_cap at (module_end - addr) stops a name with no NUL before
192 // the module boundary from reading forward into an adjacent mapped image and returning another module's bytes
193 // as a confident name. A zero module_end means no bound was supplied (only the length caps apply).
194
2/2
✓ Branch 7 → 8 taken 123 times.
✓ Branch 7 → 12 taken 2 times.
125 if (module_end != 0)
195 {
196
2/2
✓ Branch 8 → 9 taken 1 time.
✓ Branch 8 → 10 taken 122 times.
123 if (module_end <= addr)
197 1 return 0;
198 122 const std::size_t to_module_end = static_cast<std::size_t>(module_end - addr);
199
2/2
✓ Branch 10 → 11 taken 1 time.
✓ Branch 10 → 14 taken 121 times.
122 if (to_module_end < accum_cap)
200 1 accum_cap = to_module_end;
201 }
202 else
203 {
204 // The address space is the one bound the unbounded mode still has. Capping at the bytes at or above
205 // addr keeps every cur inside [addr, UINTPTR_MAX], so no iteration reads from a wrapped address.
206 // addr >= MIN_VALID_PTR above, so the increment cannot overflow. Proof:
207 // RttiTest.ReadNameSehClampsToModuleEnd.
208 2 const std::size_t to_space_end = static_cast<std::size_t>(UINTPTR_MAX - addr) + 1;
209
2/2
✓ Branch 12 → 13 taken 1 time.
✓ Branch 12 → 14 taken 1 time.
2 if (to_space_end < accum_cap)
210 1 accum_cap = to_space_end;
211 }
212
213 124 std::size_t written = 0;
214 124 bool found_nul = false;
215 124 bool read_failed = false;
216
217
2/2
✓ Branch 29 → 15 taken 124 times.
✓ Branch 29 → 30 taken 9 times.
133 while (written < accum_cap)
218 {
219 124 const std::uintptr_t cur = addr + written;
220 124 const std::size_t to_page_end = static_cast<std::size_t>(PAGE_SIZE - (cur & PAGE_MASK));
221 124 const std::size_t remaining = accum_cap - written;
222
2/2
✓ Branch 15 → 16 taken 88 times.
✓ Branch 15 → 17 taken 36 times.
124 const std::size_t chunk = (to_page_end < remaining) ? to_page_end : remaining;
223
224 char buf[256];
225 124 const std::size_t this_read = (chunk > sizeof(buf)) ? sizeof(buf) : chunk;
226
2/2
✓ Branch 19 → 20 taken 3 times.
✓ Branch 19 → 21 taken 121 times.
124 if (!DetourModKit::detail::guarded_read_bytes(cur, buf, this_read))
227 {
228 3 read_failed = true;
229 115 break;
230 }
231
232 121 const auto *nul = static_cast<const char *>(std::memchr(buf, 0, this_read));
233
2/2
✓ Branch 22 → 23 taken 112 times.
✓ Branch 22 → 24 taken 9 times.
121 const std::size_t copy_len = nul ? static_cast<std::size_t>(nul - buf) : this_read;
234 121 std::memcpy(tmp + written, buf, copy_len);
235 121 written += copy_len;
236
2/2
✓ Branch 25 → 26 taken 112 times.
✓ Branch 25 → 27 taken 9 times.
121 if (nul)
237 {
238 112 found_nul = true;
239 112 break;
240 }
241 }
242
243 // Commit only when either a NUL was found or the buffer was filled to accum_cap without faulting. A read fault
244 // before either condition is reported as a clean failure: the caller-visible buffer stays empty and the return
245 // is zero.
246
3/4
✓ Branch 30 → 31 taken 3 times.
✓ Branch 30 → 33 taken 121 times.
✓ Branch 31 → 32 taken 3 times.
✗ Branch 31 → 33 not taken.
124 if (read_failed && !found_nul)
247 {
248 3 out[0] = '\0';
249 3 return 0;
250 }
251
252 121 std::memcpy(out, tmp, written);
253 121 out[written] = '\0';
254 121 return written;
255 }
256
257 namespace
258 {
259 /**
260 * @brief Resolves the address of the mangled-name buffer for @p vtable, plus its owning-module end.
261 * @details Thin wrapper over rtti::detail::resolve_col_site that keeps the forward walker's "name address or
262 * zero" contract. Every failure mode of the prelude collapses to a 0 return. @p module_end_out
263 * receives the owning module's exclusive end so the caller's name read can clamp to the module (the
264 * RTTI-name over-read guard); it is left untouched on a 0 return.
265 * @return Address of the first byte of the NUL-terminated name, or 0 on any failure.
266 */
267 62 std::uintptr_t resolve_name_site(std::uintptr_t vtable, std::uintptr_t &module_end_out) noexcept
268 {
269 62 rtti::detail::ColSite site;
270
2/2
✓ Branch 3 → 4 taken 13 times.
✓ Branch 3 → 5 taken 49 times.
62 if (!rtti::detail::resolve_col_site(vtable, site))
271 13 return 0;
272 49 module_end_out = site.module_end;
273 49 return site.name_addr;
274 }
275 } // anonymous namespace
276
277 11 std::optional<std::string> rtti::type_name_of(Address vtable, std::size_t max_len) noexcept
278 {
279 11 std::uintptr_t module_end = 0;
280 11 const std::uintptr_t name_addr = resolve_name_site(vtable.raw(), module_end);
281
2/2
✓ Branch 4 → 5 taken 5 times.
✓ Branch 4 → 6 taken 6 times.
11 if (name_addr == 0)
282 5 return std::nullopt;
283
284
2/2
✓ Branch 6 → 7 taken 1 time.
✓ Branch 6 → 8 taken 5 times.
6 if (max_len == 0)
285 1 max_len = DEFAULT_TYPE_NAME_MAX;
286
1/2
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 6 times.
6 if (max_len > MAX_TYPE_NAME_LEN)
287 max_len = MAX_TYPE_NAME_LEN;
288
289 6 std::string out;
290 try
291 {
292
1/2
✓ Branch 11 → 12 taken 6 times.
✗ Branch 11 → 22 not taken.
6 out.resize(max_len + 1);
293 }
294 catch (...)
295 {
296 return std::nullopt;
297 }
298 6 const std::size_t len = detail::read_name_seh(name_addr, out.data(), out.size(), module_end);
299
2/2
✓ Branch 15 → 16 taken 1 time.
✓ Branch 15 → 17 taken 5 times.
6 if (len == 0)
300 1 return std::nullopt;
301 5 out.resize(len);
302 5 return out;
303 6 }
304
305 6 std::size_t rtti::type_name_into(Address vtable, char *out, std::size_t out_len) noexcept
306 {
307
4/4
✓ Branch 2 → 3 taken 5 times.
✓ Branch 2 → 4 taken 1 time.
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 4 times.
6 if (!out || out_len == 0)
308 2 return 0;
309 4 out[0] = '\0';
310 4 std::uintptr_t module_end = 0;
311 4 const std::uintptr_t name_addr = resolve_name_site(vtable.raw(), module_end);
312
2/2
✓ Branch 7 → 8 taken 2 times.
✓ Branch 7 → 9 taken 2 times.
4 if (name_addr == 0)
313 2 return 0;
314 2 return detail::read_name_seh(name_addr, out, out_len, module_end);
315 }
316
317 12 rtti::NameRead rtti::type_name_checked(Address vtable, char *out, std::size_t out_len) noexcept
318 {
319 12 NameRead result;
320
2/4
✓ Branch 2 → 3 taken 12 times.
✗ Branch 2 → 4 not taken.
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 12 times.
12 if (!out || out_len == 0)
321 return result;
322 12 out[0] = '\0';
323 12 std::uintptr_t module_end = 0;
324 12 const std::uintptr_t name_addr = resolve_name_site(vtable.raw(), module_end);
325
2/2
✓ Branch 7 → 8 taken 1 time.
✓ Branch 7 → 9 taken 11 times.
12 if (name_addr == 0)
326 1 return result;
327
328 11 const std::size_t written = detail::read_name_seh(name_addr, out, out_len, module_end);
329
2/2
✓ Branch 10 → 11 taken 7 times.
✓ Branch 10 → 16 taken 4 times.
11 if (written == 0)
330 {
331 // Zero bytes copied means a complete empty name or a read failure. Probe the first byte at every capacity.
332 // A failed probe keeps the default Failed status. An empty RTTI name is still a complete read. A readable
333 // non-terminator means that the name exists but no byte was copied. This case reports Truncated.
334 // RttiTest.TypeNameChecked_EmptyName* pins the matrix.
335 7 char first = 1;
336
2/2
✓ Branch 12 → 13 taken 4 times.
✓ Branch 12 → 14 taken 3 times.
7 if (DetourModKit::detail::guarded_read_bytes(name_addr, &first, 1))
337 4 result.status = (first == '\0') ? NameStatus::Ok : NameStatus::Truncated;
338 7 return result;
339 }
340 4 result.written = written;
341
342 // read_name_seh stopped at the first NUL or at the length cap. The byte immediately after the copied prefix
343 // decides which: the terminator (complete) or another name byte (truncated). A byte at or past the module
344 // boundary, or one that faults, means the name has no in-module terminator within the cap - not a trustworthy
345 // whole name, so report Truncated so an identity comparison rejects it rather than matching a prefix.
346 4 const std::uintptr_t next = name_addr + written;
347
2/4
✓ Branch 16 → 17 taken 4 times.
✗ Branch 16 → 19 not taken.
✗ Branch 17 → 18 not taken.
✓ Branch 17 → 19 taken 4 times.
4 if (module_end != 0 && next >= module_end)
348 {
349 result.status = NameStatus::Truncated;
350 return result;
351 }
352 4 char probe = 1;
353
1/2
✗ Branch 20 → 21 not taken.
✓ Branch 20 → 22 taken 4 times.
4 if (!DetourModKit::detail::guarded_read_bytes(next, &probe, 1))
354 {
355 result.status = NameStatus::Truncated;
356 return result;
357 }
358 4 result.status = (probe == '\0') ? NameStatus::Ok : NameStatus::Truncated;
359 4 return result;
360 }
361
362 37 bool rtti::vtable_is_type(Address vtable, std::string_view expected) noexcept
363 {
364
6/6
✓ Branch 3 → 4 taken 36 times.
✓ Branch 3 → 6 taken 1 time.
✓ Branch 5 → 6 taken 1 time.
✓ Branch 5 → 7 taken 35 times.
✓ Branch 8 → 9 taken 2 times.
✓ Branch 8 → 10 taken 35 times.
37 if (expected.empty() || expected.size() >= MAX_TYPE_NAME_LEN)
365 2 return false;
366
367 35 std::uintptr_t module_end = 0;
368 35 const std::uintptr_t name_addr = resolve_name_site(vtable.raw(), module_end);
369
2/2
✓ Branch 12 → 13 taken 5 times.
✓ Branch 12 → 14 taken 30 times.
35 if (name_addr == 0)
370 5 return false;
371
372 // Read expected.size() + 1 bytes to capture the terminating NUL. A name that lacks the NUL at expected.size()
373 // is either longer (a superstring) or unreadable past that point; both are rejected.
374 char buf[MAX_TYPE_NAME_LEN + 1];
375 30 const std::size_t need = expected.size() + 1;
376 // Fail closed if the compare would read to or past the owning module's end. A matching in-module name has its
377 // NUL before module_end, so a name buffer within `need` bytes of the boundary cannot equal `expected`, and the
378 // read must not spill into an adjacent mapped image. (module_end > name_addr is guaranteed by resolve_col_site;
379 // the <= guard is defensive.)
380
2/4
✓ Branch 15 → 16 taken 30 times.
✗ Branch 15 → 17 not taken.
✗ Branch 16 → 17 not taken.
✓ Branch 16 → 18 taken 30 times.
30 if (module_end <= name_addr || module_end - name_addr < need)
381 return false;
382
1/2
✗ Branch 19 → 20 not taken.
✓ Branch 19 → 21 taken 30 times.
30 if (!DetourModKit::detail::guarded_read_bytes(name_addr, buf, need))
383 return false;
384
2/2
✓ Branch 22 → 23 taken 4 times.
✓ Branch 22 → 24 taken 26 times.
30 if (buf[expected.size()] != '\0')
385 4 return false;
386 26 return std::memcmp(buf, expected.data(), expected.size()) == 0;
387 }
388
389 1 void rtti::PointerTableCache::reset() noexcept
390 {
391
1/2
✗ Branch 6 → 3 not taken.
✓ Branch 6 → 7 taken 1 time.
2 while (m_writer.test_and_set(std::memory_order_acquire))
392 (void)::SwitchToThread();
393
394 1 const std::uint32_t sequence = m_seq.load(std::memory_order_relaxed);
395 1 m_seq.store(sequence + 1, std::memory_order_relaxed);
396 std::atomic_thread_fence(std::memory_order_release);
397 1 m_vtable.store(Address{}, std::memory_order_relaxed);
398 1 m_image_base.store(Address{}, std::memory_order_relaxed);
399 1 m_generation.store(0, std::memory_order_relaxed);
400 1 m_epoch.fetch_add(1, std::memory_order_relaxed);
401 std::atomic_thread_fence(std::memory_order_release);
402 1 m_seq.store(sequence + 2, std::memory_order_relaxed);
403 1 m_writer.clear(std::memory_order_release);
404 1 }
405
406 25 std::optional<Address> rtti::find_in_pointer_table(
407 Address table,
408 std::size_t slot_count,
409 std::string_view expected,
410 std::atomic<Address> *vtable_cache,
411 std::size_t stride
412 ) noexcept
413 {
414
8/8
✓ Branch 3 → 4 taken 24 times.
✓ Branch 3 → 7 taken 1 time.
✓ Branch 4 → 5 taken 23 times.
✓ Branch 4 → 7 taken 1 time.
✓ Branch 6 → 7 taken 1 time.
✓ Branch 6 → 8 taken 22 times.
✓ Branch 9 → 10 taken 3 times.
✓ Branch 9 → 11 taken 22 times.
25 if (table.raw() < detail::MIN_VALID_PTR || slot_count == 0 || expected.empty())
415 3 return std::nullopt;
416
2/2
✓ Branch 11 → 12 taken 1 time.
✓ Branch 11 → 13 taken 21 times.
22 if (stride == 0)
417 1 stride = sizeof(std::uintptr_t);
418
419 // Overflow guard on the addressable span. Two failure modes are possible:
420 // 1. (slot_count * stride) overflows std::size_t.
421 // 2. (table + span) overflows std::uintptr_t.
422 // The first check rejects (1) directly; the second catches (2) by comparing the wrapped sum against the base. A
423 // malformed (table, stride, slot_count) tuple is treated as an empty table.
424
1/2
✗ Branch 13 → 14 not taken.
✓ Branch 13 → 15 taken 22 times.
22 if (slot_count > SIZE_MAX / stride)
425 return std::nullopt;
426 22 const std::uintptr_t table_raw = table.raw();
427 22 const std::uintptr_t span = static_cast<std::uintptr_t>(slot_count * stride);
428
2/2
✓ Branch 16 → 17 taken 1 time.
✓ Branch 16 → 18 taken 21 times.
22 if (table_raw + span < table_raw)
429 1 return std::nullopt;
430
431 21 Address cached_vt{};
432
2/2
✓ Branch 18 → 19 taken 15 times.
✓ Branch 18 → 20 taken 6 times.
21 if (vtable_cache)
433 15 cached_vt = vtable_cache->load(std::memory_order_relaxed);
434
435 22 const auto scan = [&](Address warm_vtable) noexcept -> std::optional<Address>
436 {
437
2/2
✓ Branch 43 → 3 taken 38 times.
✓ Branch 43 → 44 taken 5 times.
43 for (std::size_t i = 0; i < slot_count; ++i)
438 {
439 38 const std::uintptr_t slot_addr = table_raw + i * stride;
440 38 const auto obj_opt = DetourModKit::detail::guarded_read<std::uintptr_t>(slot_addr);
441
5/6
✓ Branch 5 → 6 taken 38 times.
✗ Branch 5 → 8 not taken.
✓ Branch 7 → 8 taken 13 times.
✓ Branch 7 → 9 taken 25 times.
✓ Branch 10 → 11 taken 13 times.
✓ Branch 10 → 12 taken 25 times.
38 if (!obj_opt || *obj_opt < detail::MIN_VALID_PTR)
442 15 continue;
443 25 const std::uintptr_t obj = *obj_opt;
444
445 25 const auto vt_opt = DetourModKit::detail::guarded_read<std::uintptr_t>(obj);
446
3/6
✓ Branch 15 → 16 taken 25 times.
✗ Branch 15 → 18 not taken.
✗ Branch 17 → 18 not taken.
✓ Branch 17 → 19 taken 25 times.
✗ Branch 20 → 21 not taken.
✓ Branch 20 → 22 taken 25 times.
25 if (!vt_opt || *vt_opt < detail::MIN_VALID_PTR)
447 continue;
448 25 const Address vt{*vt_opt};
449
450
2/2
✓ Branch 25 → 26 taken 7 times.
✓ Branch 25 → 32 taken 18 times.
25 if (warm_vtable)
451 {
452
2/2
✓ Branch 27 → 28 taken 5 times.
✓ Branch 27 → 31 taken 2 times.
7 if (vt == warm_vtable)
453 5 return Address{obj};
454 2 continue;
455 }
456
457
2/2
✓ Branch 33 → 34 taken 12 times.
✓ Branch 33 → 39 taken 6 times.
18 if (vtable_is_type(vt, expected))
458 {
459
2/2
✓ Branch 34 → 35 taken 7 times.
✓ Branch 34 → 36 taken 5 times.
12 if (vtable_cache)
460 7 vtable_cache->store(vt, std::memory_order_relaxed);
461 12 return Address{obj};
462 }
463 }
464 5 return std::nullopt;
465 21 };
466
467
2/2
✓ Branch 21 → 22 taken 6 times.
✓ Branch 21 → 30 taken 15 times.
21 if (cached_vt)
468 {
469
2/2
✓ Branch 24 → 25 taken 5 times.
✓ Branch 24 → 26 taken 1 time.
6 if (const auto warm = scan(cached_vt))
470 5 return warm;
471
472 // No slot carried the cached vtable. Clear only the value this call observed, then run one cold pass so a
473 // remapped table refreshes immediately instead of remaining wedged on a stale address.
474
1/2
✓ Branch 26 → 27 taken 1 time.
✗ Branch 26 → 30 not taken.
1 if (vtable_cache)
475 {
476 1 Address expected_cache = cached_vt;
477 1 (void)vtable_cache->compare_exchange_strong(expected_cache, Address{}, std::memory_order_relaxed);
478 }
479 }
480 16 return scan(Address{});
481 }
482
483 12 std::optional<Address> rtti::find_in_pointer_table(
484 Address table,
485 std::size_t slot_count,
486 std::string_view expected,
487 PointerTableCache &cache,
488 std::size_t stride
489 ) noexcept
490 {
491 struct CacheSnapshot
492 {
493 Address vtable{};
494 Address image_base{};
495 std::uint64_t generation{0};
496 std::uint64_t epoch{0};
497 };
498
499 12 const auto load_cache = [&cache]() noexcept -> CacheSnapshot
500 {
501 12 constexpr std::size_t MAX_ATTEMPTS = 16;
502
1/2
✓ Branch 39 → 3 taken 12 times.
✗ Branch 39 → 40 not taken.
12 for (std::size_t attempt = 0; attempt < MAX_ATTEMPTS; ++attempt)
503 {
504 12 const std::uint32_t sequence = cache.m_seq.load(std::memory_order_acquire);
505
1/2
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 12 taken 12 times.
12 if ((sequence & 1U) != 0U)
506 continue;
507 const CacheSnapshot snapshot{
508 12 cache.m_vtable.load(std::memory_order_relaxed),
509 12 cache.m_image_base.load(std::memory_order_relaxed),
510 12 cache.m_generation.load(std::memory_order_relaxed),
511 24 cache.m_epoch.load(std::memory_order_relaxed)
512 24 };
513 std::atomic_thread_fence(std::memory_order_acquire);
514
1/2
✓ Branch 36 → 37 taken 12 times.
✗ Branch 36 → 38 not taken.
24 if (cache.m_seq.load(std::memory_order_relaxed) == sequence)
515 12 return snapshot;
516 }
517 return {};
518 12 };
519
520 7 const auto publish_cache = [&cache](CacheSnapshot desired, CacheSnapshot expected_snapshot) noexcept
521 {
522
1/2
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 7 times.
14 if (cache.m_writer.test_and_set(std::memory_order_acquire))
523 return;
524 7 if (cache.m_vtable.load(std::memory_order_relaxed) != expected_snapshot.vtable ||
525
1/2
✓ Branch 11 → 12 taken 7 times.
✗ Branch 11 → 28 not taken.
7 cache.m_image_base.load(std::memory_order_relaxed) != expected_snapshot.image_base ||
526
3/6
✓ Branch 8 → 9 taken 7 times.
✗ Branch 8 → 28 not taken.
✓ Branch 19 → 20 taken 7 times.
✗ Branch 19 → 28 not taken.
✗ Branch 30 → 31 not taken.
✓ Branch 30 → 40 taken 7 times.
28 cache.m_generation.load(std::memory_order_relaxed) != expected_snapshot.generation ||
527
1/2
✗ Branch 27 → 28 not taken.
✓ Branch 27 → 29 taken 7 times.
14 cache.m_epoch.load(std::memory_order_relaxed) != expected_snapshot.epoch)
528 {
529 cache.m_writer.clear(std::memory_order_release);
530 return;
531 }
532 7 const std::uint32_t sequence = cache.m_seq.load(std::memory_order_relaxed);
533 7 cache.m_seq.store(sequence + 1, std::memory_order_relaxed);
534 std::atomic_thread_fence(std::memory_order_release);
535 7 cache.m_vtable.store(desired.vtable, std::memory_order_relaxed);
536 7 cache.m_image_base.store(desired.image_base, std::memory_order_relaxed);
537 7 cache.m_generation.store(desired.generation, std::memory_order_relaxed);
538 std::atomic_thread_fence(std::memory_order_release);
539 7 cache.m_seq.store(sequence + 2, std::memory_order_relaxed);
540 7 cache.m_writer.clear(std::memory_order_release);
541 12 };
542
543
1/2
✓ Branch 89 → 3 taken 12 times.
✗ Branch 89 → 90 not taken.
12 for (std::size_t attempt = 0; attempt < 2; ++attempt)
544 {
545 12 const CacheSnapshot snapshot = load_cache();
546
4/6
✓ Branch 5 → 6 taken 6 times.
✓ Branch 5 → 13 taken 6 times.
✓ Branch 7 → 8 taken 6 times.
✗ Branch 7 → 13 not taken.
✓ Branch 8 → 9 taken 6 times.
✗ Branch 8 → 13 not taken.
18 const bool warm = snapshot.vtable && snapshot.image_base && snapshot.generation != 0 &&
547
2/2
✓ Branch 11 → 12 taken 4 times.
✓ Branch 11 → 13 taken 2 times.
6 current_image_stamp(snapshot.image_base.raw()) == snapshot.generation;
548
2/2
✓ Branch 14 → 15 taken 4 times.
✓ Branch 14 → 16 taken 8 times.
12 std::atomic<Address> candidate_cache{warm ? snapshot.vtable : Address{}};
549 12 const auto hit = find_in_pointer_table(table, slot_count, expected, &candidate_cache, stride);
550
2/2
✓ Branch 20 → 21 taken 3 times.
✓ Branch 20 → 28 taken 9 times.
12 if (!hit)
551 {
552
2/2
✓ Branch 22 → 23 taken 2 times.
✓ Branch 22 → 27 taken 1 time.
3 if (snapshot.vtable)
553 2 publish_cache({}, snapshot);
554 12 return std::nullopt;
555 }
556
557 9 const Address candidate_vtable = candidate_cache.load(std::memory_order_relaxed);
558
5/6
✓ Branch 29 → 30 taken 4 times.
✓ Branch 29 → 33 taken 5 times.
✓ Branch 31 → 32 taken 4 times.
✗ Branch 31 → 33 not taken.
✓ Branch 34 → 35 taken 4 times.
✓ Branch 34 → 43 taken 5 times.
9 if (warm && candidate_vtable == snapshot.vtable)
559 {
560
1/2
✓ Branch 37 → 38 taken 4 times.
✗ Branch 37 → 39 not taken.
4 if (current_image_stamp(snapshot.image_base.raw()) == snapshot.generation)
561 4 return hit;
562 publish_cache({}, snapshot);
563 continue;
564 }
565
566 5 const Region image_before = current_module_region(candidate_vtable);
567 const std::uint64_t generation_before =
568
1/2
✓ Branch 45 → 46 taken 5 times.
✗ Branch 45 → 48 not taken.
5 image_before.base ? current_image_stamp(image_before.base.raw()) : 0;
569
4/8
✓ Branch 50 → 51 taken 5 times.
✗ Branch 50 → 54 not taken.
✓ Branch 51 → 52 taken 5 times.
✗ Branch 51 → 54 not taken.
✗ Branch 53 → 54 not taken.
✓ Branch 53 → 55 taken 5 times.
✗ Branch 56 → 57 not taken.
✓ Branch 56 → 61 taken 5 times.
5 if (!image_before.base || generation_before == 0 || !vtable_is_type(candidate_vtable, expected))
570 {
571 publish_cache({}, snapshot);
572 return std::nullopt;
573 }
574
575 5 const auto object_vtable = DetourModKit::detail::guarded_read<std::uintptr_t>(hit->raw());
576 5 const Region image_after = current_module_region(candidate_vtable);
577
1/2
✓ Branch 66 → 67 taken 5 times.
✗ Branch 66 → 69 not taken.
5 const std::uint64_t generation_after = image_after.base ? current_image_stamp(image_after.base.raw()) : 0;
578
5/10
✓ Branch 71 → 72 taken 5 times.
✗ Branch 71 → 78 not taken.
✓ Branch 74 → 75 taken 5 times.
✗ Branch 74 → 78 not taken.
✓ Branch 76 → 77 taken 5 times.
✗ Branch 76 → 78 not taken.
✗ Branch 77 → 78 not taken.
✓ Branch 77 → 79 taken 5 times.
✗ Branch 80 → 81 not taken.
✓ Branch 80 → 85 taken 5 times.
5 if (!object_vtable || *object_vtable != candidate_vtable.raw() || image_after.base != image_before.base ||
579 generation_after != generation_before)
580 {
581 publish_cache({}, snapshot);
582 continue;
583 }
584
585 5 publish_cache({candidate_vtable, image_after.base, generation_after}, snapshot);
586 5 return hit;
587 }
588 return std::nullopt;
589 }
590
591 namespace
592 {
593 // Upper bound on distinct sub-object vtables collected for one mangled name. Multiple/virtual inheritance
594 // produces a handful at most; the cap keeps the reverse scan allocation-free (matches live in a stack array)
595 // and bounds a pathological duplicate-type image.
596 inline constexpr std::size_t MAX_REVERSE_MATCHES = 64;
597
598 // Cap on the readable non-executable sections collected into the stack range buffer for one reverse sweep. A
599 // normal PE keeps vtables and their RTTI meta-pointers in one or two sections (.rdata, .data), so this is
600 // generous; a scope with more qualifying sections than fit reports Traversal::Saturated rather than silently
601 // dropping the overflow.
602 inline constexpr std::size_t MAX_RTTI_SCAN_RANGES = 32;
603
604 // One readable, non-executable image-resident scan window.
605 struct ScanRange
606 {
607 std::uintptr_t begin = 0;
608 std::uintptr_t end = 0;
609 };
610
611 // A validated reverse-RTTI hit: the vtable and the COL.offset of the sub-object it belongs to (0 ==
612 // primary/most-derived).
613 struct VtMatch
614 {
615 std::uintptr_t vtable = 0;
616 std::uint32_t col_offset = 0;
617 };
618
619 81 [[nodiscard]] Region current_module_region(Address address) noexcept
620 {
621 #if defined(DMK_ENABLE_TEST_SEAMS)
622
2/2
✓ Branch 2 → 3 taken 7 times.
✓ Branch 2 → 4 taken 74 times.
81 if (auto *override_fn = DetourModKit::detail::g_rtti_module_region_override)
623 7 return override_fn(address);
624 #endif
625 74 return DetourModKit::detail::live_module_region(address);
626 }
627
628 90 [[nodiscard]] std::uint64_t current_image_stamp(std::uintptr_t module_base) noexcept
629 {
630 #if defined(DMK_ENABLE_TEST_SEAMS)
631
2/2
✓ Branch 2 → 3 taken 52 times.
✓ Branch 2 → 4 taken 38 times.
90 if (auto *override_fn = DetourModKit::detail::g_rtti_image_generation_override)
632 52 return override_fn(module_base);
633 #endif
634 38 return DetourModKit::detail::image_generation_token(module_base);
635 }
636
637 /**
638 * @brief Enumerates the module's readable, non-executable, non-discardable sections, where MSVC keeps vtables
639 * and their RTTI meta-pointers (.rdata for a normal /GR image, .data for a packed or section-merged
640 * one).
641 * @details .text is skipped on purpose: a vtable's [-1] COL meta-slot never lives in executable code, and
642 * sweeping code pages qword-by-qword would multiply the one-time cost for nothing. Every header field
643 * is bound- and signature-checked so a malformed or hostile image fails closed instead of being read
644 * through.
645 * @return The number of ranges written into @p out; 0 means the PE headers could not be parsed and the caller
646 * falls back to the whole module image. @p completeness is set to @ref rtti::Traversal::Incomplete when
647 * a section header could not be read after a valid prefix, or @ref rtti::Traversal::Saturated when a
648 * qualifying section did not fit @p out; it is left @ref rtti::Traversal::Complete otherwise (including
649 * the 0-return, where the caller's whole-image fallback determines its own completeness).
650 */
651 70 std::size_t collect_rtti_scan_ranges(
652 DetourModKit::detail::ModuleSpan mod,
653 ScanRange *out,
654 std::size_t cap,
655 rtti::Traversal &completeness
656 ) noexcept
657 {
658 70 const auto dos = DetourModKit::detail::guarded_read<IMAGE_DOS_HEADER>(mod.base);
659
7/8
✓ Branch 4 → 5 taken 66 times.
✓ Branch 4 → 9 taken 4 times.
✓ Branch 6 → 7 taken 20 times.
✓ Branch 6 → 9 taken 46 times.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 20 times.
✓ Branch 11 → 12 taken 50 times.
✓ Branch 11 → 13 taken 20 times.
70 if (!dos || dos->e_magic != IMAGE_DOS_SIGNATURE || dos->e_lfanew <= 0)
660 50 return 0;
661
662 20 const std::uintptr_t nt_offset = static_cast<std::uint32_t>(dos->e_lfanew);
663
1/2
✗ Branch 14 → 15 not taken.
✓ Branch 14 → 16 taken 20 times.
20 if (nt_offset >= mod.end - mod.base)
664 return 0;
665 20 const std::uintptr_t nt_addr = mod.base + nt_offset;
666 // The NT headers must lie inside the image; a wild e_lfanew is the signature of a forged or truncated
667 // header.
668
3/6
✓ Branch 17 → 18 taken 20 times.
✗ Branch 17 → 19 not taken.
✗ Branch 18 → 19 not taken.
✓ Branch 18 → 20 taken 20 times.
✗ Branch 21 → 22 not taken.
✓ Branch 21 → 23 taken 20 times.
20 if (!mod.contains(nt_addr) || mod.end - nt_addr < sizeof(IMAGE_NT_HEADERS64))
669 return 0;
670
671 20 const auto nt = DetourModKit::detail::guarded_read<IMAGE_NT_HEADERS64>(nt_addr);
672
4/8
✓ Branch 25 → 26 taken 20 times.
✗ Branch 25 → 30 not taken.
✓ Branch 27 → 28 taken 20 times.
✗ Branch 27 → 30 not taken.
✗ Branch 29 → 30 not taken.
✓ Branch 29 → 31 taken 20 times.
✗ Branch 32 → 33 not taken.
✓ Branch 32 → 34 taken 20 times.
20 if (!nt || nt->Signature != IMAGE_NT_SIGNATURE || nt->OptionalHeader.Magic != IMAGE_NT_OPTIONAL_HDR64_MAGIC)
673 return 0;
674
675 // The Windows loader caps a PE at 96 sections; a larger count is corrupt and would otherwise let the loop
676 // below run away.
677 20 const std::uint32_t num_sections = nt->FileHeader.NumberOfSections;
678
2/4
✓ Branch 35 → 36 taken 20 times.
✗ Branch 35 → 37 not taken.
✗ Branch 36 → 37 not taken.
✓ Branch 36 → 38 taken 20 times.
20 if (num_sections == 0 || num_sections > 96)
679 return 0;
680
681 // IMAGE_FIRST_SECTION: the section table starts immediately after the optional header, whose length is
682 // SizeOfOptionalHeader. Using sizeof(IMAGE_NT_HEADERS64) instead would misplace the table whenever the
683 // optional-header size differs from the compile-time struct size.
684 const std::uintptr_t sec_table =
685 20 nt_addr + offsetof(IMAGE_NT_HEADERS64, OptionalHeader) + nt->FileHeader.SizeOfOptionalHeader;
686
687 20 std::size_t count = 0;
688
2/2
✓ Branch 75 → 40 taken 313 times.
✓ Branch 75 → 76 taken 16 times.
329 for (std::uint32_t i = 0; i < num_sections; ++i)
689 {
690 313 const std::uintptr_t hdr_addr =
691 313 sec_table + static_cast<std::uintptr_t>(i) * sizeof(IMAGE_SECTION_HEADER);
692 // A section header that escapes the image, or one whose guarded read faults, truncates the enumeration
693 // after a valid prefix: the sections past it are never examined, so a unique/absent verdict built on
694 // this range set is not authoritative. Surface Incomplete rather than silently returning the prefix.
695
3/6
✓ Branch 41 → 42 taken 313 times.
✗ Branch 41 → 43 not taken.
✗ Branch 42 → 43 not taken.
✓ Branch 42 → 44 taken 313 times.
✗ Branch 45 → 46 not taken.
✓ Branch 45 → 47 taken 313 times.
313 if (!mod.contains(hdr_addr) || mod.end - hdr_addr < sizeof(IMAGE_SECTION_HEADER))
696 {
697 completeness = rtti::Traversal::Incomplete;
698 4 break;
699 }
700
701 313 const auto sec = DetourModKit::detail::guarded_read<IMAGE_SECTION_HEADER>(hdr_addr);
702
2/2
✓ Branch 49 → 50 taken 2 times.
✓ Branch 49 → 51 taken 311 times.
313 if (!sec)
703 {
704 2 completeness = rtti::Traversal::Incomplete;
705 2 break;
706 }
707
708 311 const std::uint32_t ch = sec->Characteristics;
709 311 const bool readable = (ch & IMAGE_SCN_MEM_READ) != 0;
710 311 const bool executable = (ch & IMAGE_SCN_MEM_EXECUTE) != 0;
711 311 const bool discardable = (ch & IMAGE_SCN_MEM_DISCARDABLE) != 0;
712
5/6
✓ Branch 52 → 53 taken 311 times.
✗ Branch 52 → 55 not taken.
✓ Branch 53 → 54 taken 297 times.
✓ Branch 53 → 55 taken 14 times.
✓ Branch 54 → 55 taken 118 times.
✓ Branch 54 → 56 taken 179 times.
311 if (!readable || executable || discardable)
713 134 continue;
714
715 // Use the in-memory extent (VirtualAddress + VirtualSize), never the on-disk
716 // PointerToRawData/SizeOfRawData: those are file offsets and do not survive section alignment once
717 // mapped.
718
1/2
✗ Branch 57 → 58 not taken.
✓ Branch 57 → 59 taken 179 times.
179 if (sec->Misc.VirtualSize == 0)
719 continue;
720
2/2
✓ Branch 60 → 61 taken 2 times.
✓ Branch 60 → 63 taken 177 times.
179 if (sec->VirtualAddress >= mod.end - mod.base)
721 {
722 2 completeness = merge_traversal(completeness, rtti::Traversal::Incomplete);
723 2 continue;
724 }
725 177 const std::uintptr_t begin = mod.base + sec->VirtualAddress;
726
1/2
✗ Branch 65 → 66 not taken.
✓ Branch 65 → 68 taken 177 times.
177 if (sec->Misc.VirtualSize > mod.end - begin)
727 {
728 completeness = merge_traversal(completeness, rtti::Traversal::Incomplete);
729 continue;
730 }
731 177 const std::uintptr_t end = begin + sec->Misc.VirtualSize;
732
733 // A qualifying section that will not fit the caller's fixed range buffer means the sweep would omit a
734 // region that could hold the type: report Saturated so the verdict is not treated as authoritative.
735
2/2
✓ Branch 69 → 70 taken 2 times.
✓ Branch 69 → 71 taken 175 times.
177 if (count == cap)
736 {
737 2 completeness = rtti::Traversal::Saturated;
738 2 break;
739 }
740 175 out[count].begin = begin;
741 175 out[count].end = end;
742 175 ++count;
743 }
744 20 return count;
745 }
746
747 /**
748 * @brief Byte-exact comparison of the NUL-terminated name at @p name_addr with @p mangled, including the
749 * terminator so a superstring does not match.
750 * @details Mirrors vtable_is_type's name check; the caller guarantees mangled.size() < MAX_TYPE_NAME_LEN.
751 * @param module_end Exclusive end of the name's owning module; the compare fails closed rather than reading to
752 * or past it, so an edge-of-module name buffer cannot over-read into an adjacent mapped image.
753 */
754 [[nodiscard]] bool
755 183 name_equals(std::uintptr_t name_addr, std::string_view mangled, std::uintptr_t module_end) noexcept
756 {
757 char buf[rtti::MAX_TYPE_NAME_LEN + 1];
758 183 const std::size_t need = mangled.size() + 1;
759
2/4
✓ Branch 3 → 4 taken 183 times.
✗ Branch 3 → 5 not taken.
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 183 times.
183 if (module_end <= name_addr || module_end - name_addr < need)
760 return false;
761
1/2
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 183 times.
183 if (!DetourModKit::detail::guarded_read_bytes(name_addr, buf, need))
762 return false;
763
2/2
✓ Branch 10 → 11 taken 3 times.
✓ Branch 10 → 12 taken 180 times.
183 if (buf[mangled.size()] != '\0')
764 3 return false;
765 180 return std::memcmp(buf, mangled.data(), mangled.size()) == 0;
766 }
767
768 /**
769 * @brief Sweeps one [begin,end) window for vtables whose RTTI name equals @p mangled, appending validated,
770 * deduped matches to @p out (capped at @p cap).
771 * @details The window is read in page-bounded chunks (one guarded read per page) and scanned in-process, so the
772 * guarded-read count is per-page rather than per-qword - the difference between a few hundred and a
773 * few hundred thousand guarded transitions over a multi-megabyte section. A meta-slot is a qword that
774 * points to an in-scope COL; the vtable that owns it is slot + 8, validated by the same COL prelude
775 * the forward walker uses, so the in-range pre-filter only spares a deeper read and never decides
776 * correctness.
777 * @param mod The scan SCOPE, used for the meta-slot pre-filter (a candidate's COL pointer must land in it).
778 * @param owning The vtables' OWNING-MODULE span, used to validate each candidate (see the resolve_col_site
779 * call below). May be an invalid span, in which case each candidate resolves its own module.
780 */
781 180 void sweep_range_for_name(
782 DetourModKit::detail::ModuleSpan mod,
783 DetourModKit::detail::ModuleSpan owning,
784 std::uintptr_t begin,
785 std::uintptr_t end,
786 std::string_view mangled,
787 VtMatch *out,
788 std::size_t cap,
789 std::size_t &count,
790 bool &page_unreadable
791 ) noexcept
792 {
793 // A range above UINTPTR_MAX - 7 contains no complete aligned qword. Reject it before alignment.
794
2/2
✓ Branch 2 → 3 taken 1 time.
✓ Branch 2 → 4 taken 179 times.
180 if (begin > UINTPTR_MAX - 7)
795 1 return;
796 179 std::uintptr_t addr = (begin + 7) & ~static_cast<std::uintptr_t>(7);
797
798 // Subtraction keeps each range bound valid at UINTPTR_MAX. PAGE_SIZE defines the page span.
799
5/6
✓ Branch 39 → 40 taken 11713 times.
✓ Branch 39 → 42 taken 143 times.
✓ Branch 40 → 41 taken 11677 times.
✓ Branch 40 → 42 taken 36 times.
✓ Branch 41 → 5 taken 11677 times.
✗ Branch 41 → 42 not taken.
11856 while (addr < end && end - addr >= sizeof(std::uintptr_t) && count < cap)
800 {
801 // Never let one guarded read cross a page boundary: an unmapped page then fails only its own chunk, not
802 // the whole window.
803 11677 const std::uintptr_t to_page_end = rtti::detail::PAGE_SIZE - (addr & rtti::detail::PAGE_MASK);
804 11677 const std::uintptr_t remaining = end - addr;
805
2/2
✓ Branch 5 → 6 taken 11498 times.
✓ Branch 5 → 7 taken 179 times.
11677 const std::uintptr_t chunk = (to_page_end < remaining) ? to_page_end : remaining;
806
807 // a 4 KiB page holds at most 512 qwords
808 std::uintptr_t buf[512];
809 11677 const std::size_t qwords = static_cast<std::size_t>(chunk) / sizeof(std::uintptr_t);
810 11677 const std::size_t want = (qwords < 512) ? qwords : 512;
811
2/2
✓ Branch 9 → 10 taken 3 times.
✓ Branch 9 → 11 taken 11674 times.
11677 if (!DetourModKit::detail::guarded_read_bytes(addr, buf, want * sizeof(std::uintptr_t)))
812 {
813 // A page inside a qualifying section could not be read, so a meta-slot on it (and the vtable it
814 // anchors) is invisible to this sweep. Record the gap: a unique/absent verdict built on a sweep
815 // that skipped a page is not authoritative. The page is skipped (advance past it) rather than
816 // aborting, so the readable remainder of the range is still scanned.
817 3 page_unreadable = true;
818 }
819 else
820 {
821
4/4
✓ Branch 36 → 37 taken 5893733 times.
✓ Branch 36 → 38 taken 11672 times.
✓ Branch 37 → 12 taken 5893731 times.
✓ Branch 37 → 38 taken 2 times.
5905405 for (std::size_t j = 0; j < want && count < cap; ++j)
822 {
823 // Pre-filter: a meta-slot holds a pointer to a COL inside
824 // this module. Most qwords fail here without a second read.
825
2/2
✓ Branch 13 → 14 taken 5060921 times.
✓ Branch 13 → 15 taken 832810 times.
5893731 if (!mod.contains(buf[j]))
826 5893554 continue;
827
828 832810 const std::uintptr_t slot_addr = addr + j * sizeof(std::uintptr_t);
829 832810 const std::uintptr_t candidate_vtable = slot_addr + sizeof(std::uintptr_t);
830
831 // Validate against the owning-module span the caller resolved once, not the scan scope `mod`:
832 // resolve_col_site cross-checks the recovered image base (col_addr - pSelf) against the module
833 // base and computes the TypeDescriptor / name addresses from module-base + RVA, so it needs the
834 // true module extent, which a sub-range scope (a tight fixture window whose base is not the
835 // image base) is not. Passing the pre-resolved owning span hoists the per-candidate
836 // memory::module_of loader lookup out of the hot sweep; if that one-time resolve failed (owning
837 // invalid), fall back to the self-resolving overload so behaviour is unchanged.
838 832810 rtti::detail::ColSite site;
839 832810 const bool resolved_ok = owning.valid()
840
1/2
✓ Branch 16 → 17 taken 832810 times.
✗ Branch 16 → 19 not taken.
832810 ? rtti::detail::resolve_col_site(candidate_vtable, owning, site)
841 832810 : rtti::detail::resolve_col_site(candidate_vtable, site);
842
2/2
✓ Branch 21 → 22 taken 832627 times.
✓ Branch 21 → 23 taken 183 times.
832810 if (!resolved_ok)
843 832627 continue;
844
2/2
✓ Branch 24 → 25 taken 6 times.
✓ Branch 24 → 26 taken 177 times.
183 if (!name_equals(site.name_addr, mangled, site.module_end))
845 6 continue;
846
847 177 bool seen = false;
848
2/2
✓ Branch 30 → 27 taken 4043 times.
✓ Branch 30 → 31 taken 177 times.
4220 for (std::size_t k = 0; k < count; ++k)
849 {
850
1/2
✗ Branch 27 → 28 not taken.
✓ Branch 27 → 29 taken 4043 times.
4043 if (out[k].vtable == candidate_vtable)
851 {
852 seen = true;
853 break;
854 }
855 }
856
1/2
✓ Branch 31 → 32 taken 177 times.
✗ Branch 31 → 33 not taken.
177 if (!seen)
857 {
858 177 out[count].vtable = candidate_vtable;
859 177 out[count].col_offset = site.col_offset;
860 177 ++count;
861 }
862 }
863 }
864
865 11677 addr += want * sizeof(std::uintptr_t);
866 }
867 }
868
869 /**
870 * @brief Finds vtables for @p mangled across the module's RTTI-bearing sections, returning the count written
871 * into @p out (deduped, capped at @p cap).
872 * @details Sweeps the readable non-executable sections; if the PE headers cannot be parsed, falls back to the
873 * whole module image rather than reporting a confident-but-wrong "not found" for a packed or merged
874 * binary.
875 */
876 64 std::size_t scan_vtables_for_name(
877 DetourModKit::detail::ModuleSpan mod,
878 std::string_view mangled,
879 VtMatch *out,
880 std::size_t cap,
881 rtti::Traversal &completeness
882 ) noexcept
883 {
884
6/8
✓ Branch 3 → 4 taken 60 times.
✓ Branch 3 → 8 taken 4 times.
✓ Branch 5 → 6 taken 60 times.
✗ Branch 5 → 8 not taken.
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 60 times.
✓ Branch 10 → 11 taken 4 times.
✓ Branch 10 → 13 taken 60 times.
64 if (!mod.valid() || mangled.empty() || mangled.size() >= rtti::MAX_TYPE_NAME_LEN)
885 {
886 4 completeness = merge_traversal(completeness, rtti::Traversal::Incomplete);
887 4 return 0;
888 }
889
890 // Resolve the vtables' owning module once for the whole sweep. resolve_col_site needs the true module base
891 // and extent (for the pSelf/base cross-check and RVA->VA), which is not the scan scope `mod`: a caller may
892 // scope the sweep to a sub-range of the module (a tight fixture window), whose base is not the image base.
893 // module_of at the scope base recovers the real owning module, shared by every candidate in the scope, so
894 // the per-candidate loader lookup is hoisted here. An invalid result (the scope base is not in a loaded
895 // module) leaves each candidate to resolve its own module in sweep_range_for_name, preserving behaviour.
896 const DetourModKit::detail::ModuleSpan owning =
897 60 DetourModKit::detail::module_span(DetourModKit::detail::live_module_region(Address{mod.base}));
898
899 60 ScanRange ranges[MAX_RTTI_SCAN_RANGES];
900 60 const std::size_t range_count = collect_rtti_scan_ranges(mod, ranges, MAX_RTTI_SCAN_RANGES, completeness);
901
902 60 std::size_t count = 0;
903 60 bool page_unreadable = false;
904
2/2
✓ Branch 17 → 18 taken 45 times.
✓ Branch 17 → 19 taken 15 times.
60 if (range_count == 0)
905 {
906 // No usable scan window (the PE headers could not be parsed, or no readable non-executable section
907 // qualified): sweep the whole image rather than report a confident-but-wrong "not found" for a packed
908 // or section-merged binary. The whole-image sweep is a strict superset, and resolve_col_site still
909 // validates every candidate.
910 45 sweep_range_for_name(mod, owning, mod.base, mod.end, mangled, out, cap, count, page_unreadable);
911 }
912 else
913 {
914
3/4
✓ Branch 22 → 23 taken 135 times.
✓ Branch 22 → 24 taken 15 times.
✓ Branch 23 → 20 taken 135 times.
✗ Branch 23 → 24 not taken.
150 for (std::size_t i = 0; i < range_count && count < cap; ++i)
915 {
916 135 sweep_range_for_name(
917 mod,
918 owning,
919 ranges[i].begin,
920 ranges[i].end,
921 mangled,
922 out,
923 cap,
924 count,
925 page_unreadable
926 );
927 }
928 }
929
930 // An unreadable page anywhere in the sweep means a match could have hidden on it; a match buffer that
931 // filled to capacity means the same for the matches past the cap. Either makes a uniqueness or absence
932 // verdict unsafe, so fold both into the completeness the caller gates on.
933
2/2
✓ Branch 24 → 25 taken 3 times.
✓ Branch 24 → 27 taken 57 times.
60 if (page_unreadable)
934 3 completeness = merge_traversal(completeness, rtti::Traversal::Incomplete);
935
2/2
✓ Branch 27 → 28 taken 2 times.
✓ Branch 27 → 30 taken 58 times.
60 if (count == cap)
936 2 completeness = merge_traversal(completeness, rtti::Traversal::Saturated);
937 60 return count;
938 }
939
940 /**
941 * @brief Sweeps one [begin,end) window and returns true when it finds any validated COL.
942 * @details Mirrors the name sweep's page-bounded read, meta-slot pre-filter, and resolve_col_site validation,
943 * but returns on the first hit instead of collecting and deduping matches. The page-walk is duplicated
944 * from @ref sweep_range_for_name rather than factored into a shared callback-driven core: sharing one
945 * would complicate the security-critical name-resolve sweep purely to
946 * save the mechanical loop, and the two sweeps terminate differently (collect-to-cap versus first-hit
947 * exit). The validation authority, @ref resolve_col_site, IS shared, so the two paths cannot diverge
948 * on what counts as a resolvable COL; only the copied loop could drift, and a drift there is
949 * fail-safe (a missed COL yields a false negative that routes the caller to the raw-byte fallback).
950 * @param mod The scan SCOPE, used for the in-scope meta-slot pre-filter.
951 * @param owning The vtables' OWNING-MODULE span used for validation; may be invalid, in which case each
952 * candidate resolves its own module (mirrors @ref sweep_range_for_name).
953 */
954 47 bool sweep_range_for_first_col(
955 DetourModKit::detail::ModuleSpan mod,
956 DetourModKit::detail::ModuleSpan owning,
957 std::uintptr_t begin,
958 std::uintptr_t end,
959 bool &page_unreadable
960 ) noexcept
961 {
962
2/2
✓ Branch 2 → 3 taken 1 time.
✓ Branch 2 → 4 taken 46 times.
47 if (begin > UINTPTR_MAX - 7)
963 1 return false;
964 46 std::uintptr_t addr = (begin + 7) & ~static_cast<std::uintptr_t>(7);
965
966
4/4
✓ Branch 27 → 28 taken 5580 times.
✓ Branch 27 → 29 taken 42 times.
✓ Branch 28 → 5 taken 5578 times.
✓ Branch 28 → 29 taken 2 times.
5622 while (addr < end && end - addr >= sizeof(std::uintptr_t))
967 {
968 // One guarded read per page: an unmapped page then fails only its own chunk, not the whole window.
969 5578 const std::uintptr_t to_page_end = rtti::detail::PAGE_SIZE - (addr & rtti::detail::PAGE_MASK);
970 5578 const std::uintptr_t remaining = end - addr;
971
2/2
✓ Branch 5 → 6 taken 5532 times.
✓ Branch 5 → 7 taken 46 times.
5578 const std::uintptr_t chunk = (to_page_end < remaining) ? to_page_end : remaining;
972
973 // A 4 KiB page holds at most 512 qwords.
974 std::uintptr_t buf[512];
975 5578 const std::size_t qwords = static_cast<std::size_t>(chunk) / sizeof(std::uintptr_t);
976 5578 const std::size_t want = (qwords < 512) ? qwords : 512;
977
2/2
✓ Branch 9 → 10 taken 1 time.
✓ Branch 9 → 11 taken 5577 times.
5578 if (!DetourModKit::detail::guarded_read_bytes(addr, buf, want * sizeof(std::uintptr_t)))
978 {
979 // An unreadable page hides any COL on it; record the gap so a "no records" answer built on this
980 // sweep is reported as Incomplete rather than an authoritative absence.
981 1 page_unreadable = true;
982 }
983 else
984 {
985
2/2
✓ Branch 25 → 12 taken 2833739 times.
✓ Branch 25 → 26 taken 5575 times.
2839314 for (std::size_t j = 0; j < want; ++j)
986 {
987 // A meta-slot holds a pointer to a COL inside this module; most qwords fail this cheap
988 // in-scope pre-filter without a second read.
989
2/2
✓ Branch 13 → 14 taken 2417575 times.
✓ Branch 13 → 15 taken 416164 times.
2833739 if (!mod.contains(buf[j]))
990 2417575 continue;
991
992 416164 const std::uintptr_t slot_addr = addr + j * sizeof(std::uintptr_t);
993 416164 const std::uintptr_t candidate_vtable = slot_addr + sizeof(std::uintptr_t);
994
995 // resolve_col_site is the shared definition of "resolvable", so this predicate and the name
996 // resolver accept the same COL shapes.
997 416164 rtti::detail::ColSite site;
998 416164 const bool resolved_ok = owning.valid()
999
1/2
✓ Branch 16 → 17 taken 416164 times.
✗ Branch 16 → 19 not taken.
416164 ? rtti::detail::resolve_col_site(candidate_vtable, owning, site)
1000 416164 : rtti::detail::resolve_col_site(candidate_vtable, site);
1001
2/2
✓ Branch 21 → 22 taken 2 times.
✓ Branch 21 → 23 taken 416162 times.
416164 if (resolved_ok)
1002 2 return true;
1003 }
1004 }
1005
1006 5576 addr += want * sizeof(std::uintptr_t);
1007 }
1008 44 return false;
1009 }
1010
1011 /**
1012 * @brief Reports whether @p mod holds any resolvable RTTI record, mirroring @ref scan_vtables_for_name's range
1013 * selection but short-circuiting on the first validated COL.
1014 * @details Resolves the vtables' owning-module span once (exactly as @ref scan_vtables_for_name does at its
1015 * scope base) so every candidate is validated against the true image base, then sweeps the same
1016 * readable non-executable sections, with the identical whole-image fallback when the PE headers do not
1017 * parse - a packed or section-merged image, or a tight non-PE scope window. The caller
1018 * (@ref rtti::region_has_rtti) has already proven @p mod valid.
1019 */
1020 10 rtti::RttiPresence scope_has_rtti(DetourModKit::detail::ModuleSpan mod) noexcept
1021 {
1022 const DetourModKit::detail::ModuleSpan owning =
1023 10 DetourModKit::detail::module_span(DetourModKit::detail::live_module_region(Address{mod.base}));
1024
1025 10 rtti::Traversal completeness = rtti::Traversal::Complete;
1026 10 ScanRange ranges[MAX_RTTI_SCAN_RANGES];
1027 10 const std::size_t range_count = collect_rtti_scan_ranges(mod, ranges, MAX_RTTI_SCAN_RANGES, completeness);
1028
1029 10 bool page_unreadable = false;
1030
2/2
✓ Branch 6 → 7 taken 7 times.
✓ Branch 6 → 10 taken 3 times.
10 if (range_count == 0)
1031 {
1032 // No parseable section table (packed / section-merged / a non-PE scope window): sweep the whole image
1033 // as a strict superset, exactly as scan_vtables_for_name does on the same condition.
1034
2/2
✓ Branch 8 → 9 taken 2 times.
✓ Branch 8 → 16 taken 5 times.
7 if (sweep_range_for_first_col(mod, owning, mod.base, mod.end, page_unreadable))
1035 2 return rtti::RttiPresence::Present;
1036 }
1037 else
1038 {
1039
2/2
✓ Branch 15 → 11 taken 40 times.
✓ Branch 15 → 16 taken 3 times.
43 for (std::size_t i = 0; i < range_count; ++i)
1040 {
1041
1/2
✗ Branch 12 → 13 not taken.
✓ Branch 12 → 14 taken 40 times.
40 if (sweep_range_for_first_col(mod, owning, ranges[i].begin, ranges[i].end, page_unreadable))
1042 return rtti::RttiPresence::Present;
1043 }
1044 }
1045
1046 // No record was found. Only call that an authoritative absence when the sweep was complete: a faulted
1047 // section header (in `completeness`) or an unreadable page (`page_unreadable`) could have hidden the only
1048 // record, so report Incomplete instead of a false Absent.
1049
2/2
✓ Branch 16 → 17 taken 1 time.
✓ Branch 16 → 19 taken 7 times.
8 if (page_unreadable)
1050 1 completeness = merge_traversal(completeness, rtti::Traversal::Incomplete);
1051
2/2
✓ Branch 19 → 20 taken 4 times.
✓ Branch 19 → 21 taken 4 times.
8 return (completeness == rtti::Traversal::Complete) ? rtti::RttiPresence::Absent
1052 8 : rtti::RttiPresence::Incomplete;
1053 }
1054 } // anonymous namespace
1055
1056 48 std::optional<Address> rtti::vtable_for_type(std::string_view mangled, Region range) noexcept
1057 {
1058 48 VtMatch matches[MAX_REVERSE_MATCHES];
1059 48 rtti::Traversal completeness = rtti::Traversal::Complete;
1060 48 const std::size_t match_count = scan_vtables_for_name(
1061 DetourModKit::detail::module_span(range),
1062 mangled,
1063 matches,
1064 MAX_REVERSE_MATCHES,
1065 completeness
1066 );
1067
1068 // A unique or absent verdict is the inverse of vtable_is_type and is trustworthy only across a COMPLETE sweep.
1069 // An Incomplete sweep (a faulted section header or an unreadable page) or a Saturated one (the section-range or
1070 // match buffer filled) can hide a second distinct primary, which makes a "unique" answer wrong, or the only
1071 // primary, which makes an "absent" answer wrong. Fail closed rather than authorize a verdict from a partial
1072 // sweep. Match-buffer exhaustion is reported as Traversal::Saturated by the same completeness gate.
1073
2/2
✓ Branch 4 → 5 taken 5 times.
✓ Branch 4 → 6 taken 43 times.
48 if (completeness != rtti::Traversal::Complete)
1074 5 return std::nullopt;
1075
1076 // The primary vtable is the COL.offset == 0 sub-object: the value an object pointer's first qword holds for a
1077 // most-derived instance. More than one distinct primary for the same name (a type linked into the image twice)
1078 // is ambiguous; fail closed rather than return an arbitrary one.
1079 43 std::optional<std::uintptr_t> primary;
1080
2/2
✓ Branch 20 → 7 taken 32 times.
✓ Branch 20 → 21 taken 41 times.
73 for (std::size_t i = 0; i < match_count; ++i)
1081 {
1082
2/2
✓ Branch 7 → 8 taken 1 time.
✓ Branch 7 → 9 taken 31 times.
32 if (matches[i].col_offset != 0)
1083 1 continue;
1084
5/6
✓ Branch 10 → 11 taken 2 times.
✓ Branch 10 → 14 taken 29 times.
✓ Branch 12 → 13 taken 2 times.
✗ Branch 12 → 14 not taken.
✓ Branch 15 → 16 taken 2 times.
✓ Branch 15 → 17 taken 29 times.
31 if (primary && *primary != matches[i].vtable)
1085 2 return std::nullopt;
1086 29 primary = matches[i].vtable;
1087 }
1088
1089
2/2
✓ Branch 22 → 23 taken 14 times.
✓ Branch 22 → 24 taken 27 times.
41 if (!primary)
1090 14 return std::nullopt;
1091 27 return Address{*primary};
1092 }
1093
1094 rtti::VtablesResult
1095 16 rtti::vtables_for_type_checked(std::string_view mangled, Address *out, std::size_t out_cap, Region range) noexcept
1096 {
1097
2/2
✓ Branch 2 → 3 taken 1 time.
✓ Branch 2 → 4 taken 15 times.
16 if (!out)
1098 1 out_cap = 0;
1099
1100 16 VtMatch matches[MAX_REVERSE_MATCHES];
1101 16 rtti::Traversal completeness = rtti::Traversal::Complete;
1102 16 const std::size_t match_count = scan_vtables_for_name(
1103 DetourModKit::detail::module_span(range),
1104 mangled,
1105 matches,
1106 MAX_REVERSE_MATCHES,
1107 completeness
1108 );
1109
1110 // Ascending COL.offset so the primary (offset 0) sorts first. The match count is tiny (one per base
1111 // sub-object), so an in-place insertion sort is both adequate and allocation-free.
1112
2/2
✓ Branch 12 → 7 taken 70 times.
✓ Branch 12 → 13 taken 16 times.
86 for (std::size_t i = 1; i < match_count; ++i)
1113 {
1114 70 const VtMatch key = matches[i];
1115 70 std::size_t sorted = i;
1116
4/4
✓ Branch 9 → 10 taken 71 times.
✓ Branch 9 → 11 taken 1 time.
✓ Branch 10 → 8 taken 2 times.
✓ Branch 10 → 11 taken 69 times.
72 while (sorted > 0 && matches[sorted - 1].col_offset > key.col_offset)
1117 {
1118 2 matches[sorted] = matches[sorted - 1];
1119 2 --sorted;
1120 }
1121 70 matches[sorted] = key;
1122 }
1123
1124
2/2
✓ Branch 13 → 14 taken 3 times.
✓ Branch 13 → 15 taken 13 times.
16 const std::size_t to_write = (match_count < out_cap) ? match_count : out_cap;
1125
2/2
✓ Branch 19 → 17 taken 77 times.
✓ Branch 19 → 20 taken 16 times.
93 for (std::size_t i = 0; i < to_write; ++i)
1126 77 out[i] = Address{matches[i].vtable};
1127 16 return VtablesResult{match_count, completeness};
1128 }
1129
1130 std::size_t
1131 9 rtti::vtables_for_type(std::string_view mangled, Address *out, std::size_t out_cap, Region range) noexcept
1132 {
1133 // Best-effort count-only face over the checked form: it discards the completeness, so a caller that must
1134 // distinguish an authoritative absence from a truncated sweep uses vtables_for_type_checked instead.
1135 9 return vtables_for_type_checked(mangled, out, out_cap, range).count;
1136 }
1137
1138 3 bool rtti::region_has_rtti(Region range) noexcept
1139 {
1140 // true only on a found record (sound regardless of completeness); Absent and Incomplete both collapse to the
1141 // fail-closed "no record found, use the raw-byte fallback" answer, the same one an invalid range receives.
1142 3 return region_rtti_presence(range) == RttiPresence::Present;
1143 }
1144
1145 12 rtti::RttiPresence rtti::region_rtti_presence(Region range) noexcept
1146 {
1147 12 const auto mod = DetourModKit::detail::module_span(range);
1148 // No sweep can establish absence from an invalid range.
1149
2/2
✓ Branch 4 → 5 taken 2 times.
✓ Branch 4 → 6 taken 10 times.
12 if (!mod.valid())
1150 2 return RttiPresence::Incomplete;
1151 10 return scope_has_rtti(mod);
1152 }
1153
1154 17 std::uint64_t rtti::image_generation(Address addr) noexcept
1155 {
1156 #if defined(DMK_ENABLE_TEST_SEAMS)
1157
2/2
✓ Branch 2 → 3 taken 4 times.
✓ Branch 2 → 5 taken 13 times.
17 if (auto *override_fn = DetourModKit::detail::g_rtti_image_generation_override)
1158 4 return override_fn(addr.raw());
1159 #endif
1160 13 const Region module = current_module_region(addr);
1161
2/2
✓ Branch 7 → 8 taken 12 times.
✓ Branch 7 → 10 taken 1 time.
13 return module.base ? DetourModKit::detail::image_generation_token(module.base.raw()) : 0;
1162 }
1163
1164
1/2
✓ Branch 4 → 5 taken 14 times.
✗ Branch 4 → 22 not taken.
42 rtti::TypeIdentity::TypeIdentity(std::string_view mangled, Region range) : m_mangled(mangled), m_range(range)
1165 {
1166 14 const Region module = current_module_region(range.base);
1167
3/4
✓ Branch 17 → 18 taken 3 times.
✓ Branch 17 → 20 taken 11 times.
✓ Branch 18 → 19 taken 3 times.
✗ Branch 18 → 20 not taken.
14 m_tracks_module_range = module.base == range.base && module.size == range.size;
1168 14 }
1169
1170 1 void rtti::TypeIdentity::invalidate() noexcept
1171 {
1172
1/2
✗ Branch 6 → 3 not taken.
✓ Branch 6 → 7 taken 1 time.
2 while (m_cache_writer.test_and_set(std::memory_order_acquire))
1173 (void)::SwitchToThread();
1174 1 m_cache_epoch.fetch_add(1, std::memory_order_acq_rel);
1175 1 m_resolved.store(false, std::memory_order_release);
1176 1 m_cached.store(Address{}, std::memory_order_relaxed);
1177 1 m_image_stamp.store(0, std::memory_order_relaxed);
1178 1 m_image_base.store(Address{}, std::memory_order_relaxed);
1179 1 m_last_attempt_ms.store(0, std::memory_order_relaxed);
1180 1 m_cache_writer.clear(std::memory_order_release);
1181 1 }
1182
1183 53 std::optional<Address> rtti::TypeIdentity::vtable() const noexcept
1184 {
1185 53 const std::uint64_t cache_epoch = m_cache_epoch.load(std::memory_order_acquire);
1186 23 const auto resolve_and_cache = [this](std::uint64_t expected_epoch) -> std::optional<Address>
1187 {
1188 23 Region resolve_range = m_range;
1189
2/2
✓ Branch 2 → 3 taken 5 times.
✓ Branch 2 → 11 taken 18 times.
23 if (m_tracks_module_range)
1190 {
1191 5 resolve_range = current_module_region(m_range.base);
1192
3/6
✓ Branch 5 → 6 taken 5 times.
✗ Branch 5 → 7 not taken.
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 5 times.
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 5 times.
5 if (!resolve_range.base || resolve_range.size == 0)
1193 return std::nullopt;
1194 }
1195
1196 23 const Region image_before = current_module_region(resolve_range.base);
1197
1/2
✓ Branch 13 → 14 taken 23 times.
✗ Branch 13 → 16 not taken.
23 const std::uint64_t stamp_before = image_before.base ? current_image_stamp(image_before.base.raw()) : 0;
1198 23 const auto resolved = vtable_for_type(m_mangled, resolve_range);
1199
2/2
✓ Branch 20 → 21 taken 7 times.
✓ Branch 20 → 22 taken 16 times.
23 if (!resolved)
1200 7 return std::nullopt;
1201
1202 16 const Region image_after = current_module_region(*resolved);
1203
1/2
✓ Branch 25 → 26 taken 16 times.
✗ Branch 25 → 28 not taken.
16 const std::uint64_t stamp_after = image_after.base ? current_image_stamp(image_after.base.raw()) : 0;
1204
2/4
✓ Branch 30 → 31 taken 16 times.
✗ Branch 30 → 33 not taken.
✓ Branch 31 → 32 taken 16 times.
✗ Branch 31 → 33 not taken.
16 const bool module_backed_before = image_before.base && image_before.size != 0;
1205
2/4
✓ Branch 35 → 36 taken 16 times.
✗ Branch 35 → 38 not taken.
✓ Branch 36 → 37 taken 16 times.
✗ Branch 36 → 38 not taken.
16 const bool module_backed_after = image_after.base && image_after.size != 0;
1206
3/4
✓ Branch 41 → 42 taken 16 times.
✗ Branch 41 → 45 not taken.
✓ Branch 42 → 43 taken 15 times.
✓ Branch 42 → 45 taken 1 time.
16 if (module_backed_before != module_backed_after || image_before.base != image_after.base ||
1207
5/8
✓ Branch 39 → 40 taken 16 times.
✗ Branch 39 → 45 not taken.
✓ Branch 43 → 44 taken 15 times.
✗ Branch 43 → 46 not taken.
✗ Branch 44 → 45 not taken.
✓ Branch 44 → 46 taken 15 times.
✓ Branch 47 → 48 taken 1 time.
✓ Branch 47 → 49 taken 15 times.
32 stamp_before != stamp_after || (module_backed_after && stamp_after == 0))
1208 1 return std::nullopt;
1209
1210
1/2
✗ Branch 51 → 52 not taken.
✓ Branch 51 → 53 taken 15 times.
30 if (m_cache_writer.test_and_set(std::memory_order_acquire))
1211 return std::nullopt;
1212
1/2
✗ Branch 60 → 61 not taken.
✓ Branch 60 → 70 taken 15 times.
30 if (m_cache_epoch.load(std::memory_order_acquire) != expected_epoch)
1213 {
1214 m_cache_writer.clear(std::memory_order_release);
1215 return std::nullopt;
1216 }
1217 15 m_image_base.store(image_after.base, std::memory_order_relaxed);
1218 15 m_image_stamp.store(stamp_after, std::memory_order_relaxed);
1219 15 m_cached.store(*resolved, std::memory_order_relaxed);
1220 15 m_resolved.store(true, std::memory_order_release);
1221 15 m_cache_writer.clear(std::memory_order_release);
1222 15 return resolved;
1223 53 };
1224
1225
2/2
✓ Branch 10 → 11 taken 31 times.
✓ Branch 10 → 104 taken 22 times.
53 if (m_resolved.load(std::memory_order_acquire))
1226 {
1227 31 const Address image_base = m_image_base.load(std::memory_order_relaxed);
1228 31 const std::uint64_t image_stamp = m_image_stamp.load(std::memory_order_relaxed);
1229
5/6
✓ Branch 19 → 20 taken 31 times.
✗ Branch 19 → 24 not taken.
✓ Branch 22 → 23 taken 4 times.
✓ Branch 22 → 24 taken 27 times.
✓ Branch 25 → 26 taken 4 times.
✓ Branch 25 → 83 taken 27 times.
31 if (image_stamp != 0 && current_image_stamp(image_base.raw()) != image_stamp)
1230 {
1231
1/2
✗ Branch 28 → 29 not taken.
✓ Branch 28 → 30 taken 4 times.
8 if (m_cache_writer.test_and_set(std::memory_order_acquire))
1232 return std::nullopt;
1233
1/2
✗ Branch 37 → 38 not taken.
✓ Branch 37 → 47 taken 4 times.
8 if (m_cache_epoch.load(std::memory_order_acquire) != cache_epoch)
1234 {
1235 m_cache_writer.clear(std::memory_order_release);
1236 return std::nullopt;
1237 }
1238 4 const std::uint64_t next_epoch = m_cache_epoch.fetch_add(1, std::memory_order_acq_rel) + 1;
1239 4 m_resolved.store(false, std::memory_order_release);
1240 4 m_cached.store(Address{}, std::memory_order_relaxed);
1241 4 m_image_stamp.store(0, std::memory_order_relaxed);
1242 4 m_image_base.store(Address{}, std::memory_order_relaxed);
1243 // Stamp the attempt before the gate drops: the refresh below is itself a whole-image sweep, so a miss
1244 // must start the `[B-67]` cooldown. Without the stamp the next miss-path call reads the pre-refresh
1245 // timestamp and runs a second adjacent sweep.
1246 4 const std::uint64_t refresh_now = rtti_now_ms();
1247
1/2
✓ Branch 63 → 64 taken 4 times.
✗ Branch 63 → 65 not taken.
4 m_last_attempt_ms.store(refresh_now == 0 ? 1 : refresh_now, std::memory_order_release);
1248 4 m_cache_writer.clear(std::memory_order_release);
1249 4 return resolve_and_cache(next_epoch);
1250 }
1251 27 const Address cached = m_cached.load(std::memory_order_relaxed);
1252
2/4
✓ Branch 91 → 92 taken 27 times.
✗ Branch 91 → 94 not taken.
✗ Branch 96 → 97 not taken.
✓ Branch 96 → 98 taken 27 times.
81 if (m_cache_epoch.load(std::memory_order_acquire) != cache_epoch ||
1253
1/2
✗ Branch 93 → 94 not taken.
✓ Branch 93 → 95 taken 27 times.
27 !m_resolved.load(std::memory_order_acquire))
1254 return std::nullopt;
1255
1/2
✓ Branch 99 → 100 taken 27 times.
✗ Branch 99 → 101 not taken.
27 return cached ? std::optional<Address>(cached) : std::nullopt;
1256 }
1257
1258 // Miss path. vtable_for_type sweeps every RTTI-bearing section (a heavy walk), so throttle the re-sweep:
1259 // after a miss, skip until RESOLVE_RETRY_COOLDOWN_MS has elapsed, turning a per-frame full-module scan for an
1260 // absent type into at most one scan per cooldown while still eventually retrying. last == 0 is the
1261 // never-attempted sentinel; the now >= last guard keeps a non-monotonic clock from underflowing into a skip.
1262 22 const std::uint64_t now = rtti_now_ms();
1263
1/2
✗ Branch 107 → 108 not taken.
✓ Branch 107 → 109 taken 22 times.
44 if (m_cache_writer.test_and_set(std::memory_order_acquire))
1264 return std::nullopt;
1265
1/2
✗ Branch 116 → 117 not taken.
✓ Branch 116 → 126 taken 22 times.
44 if (m_cache_epoch.load(std::memory_order_acquire) != cache_epoch)
1266 {
1267 m_cache_writer.clear(std::memory_order_release);
1268 return std::nullopt;
1269 }
1270 22 const std::uint64_t last = m_last_attempt_ms.load(std::memory_order_acquire);
1271
5/6
✓ Branch 133 → 134 taken 7 times.
✓ Branch 133 → 145 taken 15 times.
✓ Branch 134 → 135 taken 7 times.
✗ Branch 134 → 145 not taken.
✓ Branch 135 → 136 taken 3 times.
✓ Branch 135 → 145 taken 4 times.
22 if (last != 0 && now >= last && (now - last) < RESOLVE_RETRY_COOLDOWN_MS)
1272 {
1273 3 m_cache_writer.clear(std::memory_order_release);
1274 3 return std::nullopt;
1275 }
1276
1/2
✓ Branch 145 → 146 taken 19 times.
✗ Branch 145 → 147 not taken.
19 m_last_attempt_ms.store(now == 0 ? 1 : now, std::memory_order_release);
1277 19 m_cache_writer.clear(std::memory_order_release);
1278 19 return resolve_and_cache(cache_epoch);
1279 }
1280
1281 19 bool rtti::TypeIdentity::matches(Address vtable) const noexcept
1282 {
1283 19 const auto resolved = TypeIdentity::vtable();
1284
4/4
✓ Branch 4 → 5 taken 18 times.
✓ Branch 4 → 9 taken 1 time.
✓ Branch 7 → 8 taken 17 times.
✓ Branch 7 → 9 taken 1 time.
19 return resolved.has_value() && *resolved == vtable;
1285 }
1286 } // namespace DetourModKit
1287