./
Coverage:
low: ≥ 0%
medium: ≥ 75.0%
high: ≥ 90.0%
Lines:
372 / 413
90.1%
Branches:
243 / 293
82.9%
Functions:
31 / 33
93.9%

src/rtti_dissect.cpp
Line Branch Exec Source
1 /**
2 * @file rtti_dissect.cpp
3 * @brief Reverse-direction RTTI dissection, self-healing offset resolvers, and the frame-scheduled heal runner.
4 *
5 * Builds on top of the verified COL prelude shared with rtti.cpp:
6 * L1 identify_pointee_type: reverse-identify the object behind one slot.
7 * L2 reverse_scan_block: RTTI-label a block of slots (tooling).
8 * L3 heal_landmark: self-heal one field offset after a patch.
9 * L4 solve_fingerprint: recover one uniform shift across many fields.
10 * L5 HealScheduler: drive the heals on a frame cadence, latch per group, warn once on real drift.
11 *
12 * Every L1-L4 entry point is noexcept and fails closed. The hot self-heal path allocates nothing, because it reuses one
13 * stack PointeeType. Only the explicitly tooling-only block scanner grows a vector. All reads go through the
14 * fault-guarded, module-bound-checked prelude of the forward walker. An unmapped page or forged COL is therefore a
15 * clean non-match, never a fault. Matching is byte-exact on the MSVC most-derived mangled name
16 * (no UnDecorateSymbolName).
17 *
18 * The public surface uses Address and reports failures through the ErrorCategory::Rtti block. Address <-> integer
19 * punning is confined to the raw-slot arithmetic below.
20 */
21
22 #include "DetourModKit/rtti_dissect.hpp"
23 #include "DetourModKit/logger.hpp"
24
25 #include "internal/memory_guarded.hpp"
26 #include "internal/rtti_shared.hpp"
27
28 #include <cstdint>
29 #include <iterator>
30 #include <memory>
31 #include <utility>
32
33 namespace DetourModKit
34 {
35 #if defined(DMK_ENABLE_TEST_SEAMS)
36 namespace detail
37 {
38 void (*g_rtti_after_heal_evidence_test_hook)() noexcept = nullptr;
39 } // namespace detail
40 #endif
41
42 namespace
43 {
44 /**
45 * @brief Soft policy filter: does a resolved slot's shape (and subobject position) satisfy the landmark's
46 * required indirection?
47 * @details Indirection::Any accepts either shape; PointerToObject and ObjectBase pin the slot to the
48 * pointer-to-object or direct-object form. This is a policy-layer decision deliberately kept out
49 * of L1 so a consumer can record Any when capture and heal may straddle a DLL boundary.
50 * CompleteObject adds a subobject constraint on top of the direct-object shape, which is why
51 * @p col_offset is consulted here.
52 * @param was_pointer The resolved slot's shape (PointeeType::was_pointer).
53 * @param col_offset The resolved object's COL.offset (PointeeType::col_offset): 0 for the primary subobject,
54 * nonzero for a multiple-inheritance secondary base.
55 * @param ind The landmark's required indirection.
56 */
57 80 [[nodiscard]] bool shape_ok(bool was_pointer, std::uint32_t col_offset, rtti::Indirection ind) noexcept
58 {
59
4/5
✓ Branch 2 → 3 taken 2 times.
✓ Branch 2 → 4 taken 68 times.
✓ Branch 2 → 5 taken 4 times.
✓ Branch 2 → 6 taken 6 times.
✗ Branch 2 → 11 not taken.
80 switch (ind)
60 {
61 2 case rtti::Indirection::Any:
62 2 return true;
63 68 case rtti::Indirection::PointerToObject:
64 68 return was_pointer;
65 4 case rtti::Indirection::ObjectBase:
66 4 return !was_pointer;
67 6 case rtti::Indirection::CompleteObject:
68 // A direct object base pinned to the most-derived (primary) subobject. Under multiple inheritance every
69 // base subobject has its own vtable, and each vtable's COL names the same complete type. COL.offset
70 // distinguishes those subobjects; the primary subobject has col_offset == 0, so its base is the
71 // complete object. Rejecting col_offset != 0 keeps a heal from latching a secondary base's adjacent
72 // vtable and reporting an offset shifted by that subobject delta.
73
4/4
✓ Branch 6 → 7 taken 5 times.
✓ Branch 6 → 9 taken 1 time.
✓ Branch 7 → 8 taken 3 times.
✓ Branch 7 → 9 taken 2 times.
6 return !was_pointer && col_offset == 0;
74 }
75 ✗ return false;
76 }
77
78 /**
79 * @brief Probe one slot: resolve, check shape, byte-exact name match.
80 * @details Fills @p pt whenever the slot resolves (so the caller can read the match details), and reports
81 * whether it also passed the shape filter and the exact mangled-name compare. The name compare reuses
82 * the same semantics as vtable_is_type: a superstring or a differing byte fails.
83 * @return true only on a full resolve + shape + exact-name match.
84 */
85 385 [[nodiscard]] bool slot_matches(std::uintptr_t addr, const rtti::Landmark &lm, rtti::PointeeType &pt) noexcept
86 {
87
2/2
✓ Branch 4 → 5 taken 305 times.
✓ Branch 4 → 6 taken 80 times.
385 if (!rtti::identify_pointee_type(Address{addr}, pt))
88 305 return false;
89
2/2
✓ Branch 7 → 8 taken 5 times.
✓ Branch 7 → 9 taken 75 times.
80 if (!shape_ok(pt.was_pointer, pt.col_offset, lm.indirection))
90 5 return false;
91 75 return pt.name() == lm.expected_mangled;
92 }
93
94 /**
95 * @brief Builds a HealHit from a matched slot.
96 * @details healed_offset is the field's offset within the struct base (slot_addr - base), the value a consumer
97 * feeds straight into a pointer chain. It equals nominal_offset when the layout did not drift and
98 * nominal_offset +/- delta after a shift.
99 */
100 [[nodiscard]] rtti::HealHit
101 34 make_hit(std::uintptr_t slot_addr, std::uintptr_t base, const rtti::PointeeType &pt) noexcept
102 {
103 34 rtti::HealHit h;
104 34 h.healed_offset = rtti::detail::address_offset(slot_addr, base);
105 34 h.slot_addr = Address{slot_addr};
106 34 h.object_addr = pt.object_base;
107 34 h.vtable = pt.vtable;
108 34 h.col_offset = pt.col_offset;
109 34 h.was_pointer = pt.was_pointer;
110 34 return h;
111 }
112
113 /**
114 * @brief Re-establishes a matched slot's type evidence between two equal image-generation reads.
115 * @details rtti::image_generation folds {live module base, PE identity}, so two equal nonzero reads that
116 * bracket a COL/name walk attest that one image produced the evidence. A token sampled only after the
117 * walk cannot: a fixed-base replacement landing between the walk and the sample would authorize the
118 * previous image's offset under the replacement's token, which is the exact authorization a consumer
119 * reads as proof that the layout still holds. The re-walk costs one extra resolve, paid only on a slot
120 * that already matched.
121 * @return The bracketed generation, or 0 when the image moved, the evidence no longer holds, or the vtable's
122 * image is untracked.
123 */
124 5 [[nodiscard]] std::uint64_t bracketed_generation(const rtti::Landmark &lm, const rtti::HealHit &hit) noexcept
125 {
126 5 const std::uint64_t before = rtti::image_generation(hit.vtable);
127
2/2
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 4 times.
5 if (before == 0)
128 1 return 0;
129
130 4 rtti::PointeeType pt;
131
2/2
✓ Branch 7 → 8 taken 1 time.
✓ Branch 7 → 9 taken 3 times.
4 if (!slot_matches(hit.slot_addr.raw(), lm, pt))
132 1 return 0;
133 // The same slot must still yield the same object, vtable, and subobject position. A replacement that
134 // happens to publish a same-named type at another address is a different layout, not a re-confirmation.
135
4/8
✓ Branch 10 → 11 taken 3 times.
✗ Branch 10 → 15 not taken.
✓ Branch 12 → 13 taken 3 times.
✗ Branch 12 → 15 not taken.
✓ Branch 13 → 14 taken 3 times.
✗ Branch 13 → 15 not taken.
✗ Branch 17 → 18 not taken.
✓ Branch 17 → 19 taken 3 times.
6 if (pt.vtable != hit.vtable || pt.object_base != hit.object_addr || pt.col_offset != hit.col_offset ||
136
1/2
✗ Branch 14 → 15 not taken.
✓ Branch 14 → 16 taken 3 times.
3 pt.was_pointer != hit.was_pointer)
137 ✗ return 0;
138
139 #if defined(DMK_ENABLE_TEST_SEAMS)
140
2/2
✓ Branch 19 → 20 taken 1 time.
✓ Branch 19 → 21 taken 2 times.
3 if (auto *const hook = DetourModKit::detail::g_rtti_after_heal_evidence_test_hook)
141 1 hook();
142 #endif
143 3 const std::uint64_t after = rtti::image_generation(hit.vtable);
144
2/2
✓ Branch 22 → 23 taken 2 times.
✓ Branch 22 → 24 taken 1 time.
3 return after == before ? before : 0;
145 }
146
147 /**
148 * @brief Validates a landmark's type/shape descriptor fields.
149 * @details Shared by heal_from and solve_fingerprint. Does not touch @ref rtti::Landmark::base or @ref
150 * rtti::Landmark::window, which the two callers validate differently.
151 * @return true when expected_mangled is a sane length and indirection is a known enumerator.
152 */
153 83 [[nodiscard]] bool descriptor_ok(const rtti::Landmark &lm) noexcept
154 {
155
6/6
✓ Branch 3 → 4 taken 82 times.
✓ Branch 3 → 6 taken 1 time.
✓ Branch 5 → 6 taken 2 times.
✓ Branch 5 → 7 taken 80 times.
✓ Branch 8 → 9 taken 3 times.
✓ Branch 8 → 10 taken 80 times.
83 if (lm.expected_mangled.empty() || lm.expected_mangled.size() >= rtti::MAX_TYPE_NAME_LEN)
156 3 return false;
157
2/2
✓ Branch 10 → 11 taken 79 times.
✓ Branch 10 → 12 taken 1 time.
80 switch (lm.indirection)
158 {
159 79 case rtti::Indirection::PointerToObject:
160 case rtti::Indirection::ObjectBase:
161 case rtti::Indirection::CompleteObject:
162 case rtti::Indirection::Any:
163 79 return true;
164 }
165 1 return false;
166 }
167
168 /**
169 * @brief Self-heal engine shared by heal_landmark and HealRun::heal_into.
170 * @details Takes the struct @p base explicitly (rather than reading @c lm.base) so a scheduler can heal
171 * from a per-frame live base without copying the landmark. Otherwise identical to the documented
172 * heal_landmark contract: nominal short-circuit, nearest-first widened grid, equidistant tie ->
173 * HealAmbiguous, exhausted window -> HealNoMatch, malformed descriptor -> BadDescriptor.
174 */
175 55 [[nodiscard]] Result<rtti::HealHit> heal_from(const rtti::Landmark &lm, Address base) noexcept
176 {
177 // 1. Descriptor validation. Every check below touches no memory.
178
2/2
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 8 taken 54 times.
55 if (base.raw() < rtti::detail::MIN_VALID_PTR)
179 1 return std::unexpected(Error{ErrorCode::BadDescriptor, "rtti::heal_landmark", base.raw()});
180
2/2
✓ Branch 9 → 10 taken 4 times.
✓ Branch 9 → 13 taken 50 times.
54 if (!descriptor_ok(lm))
181 4 return std::unexpected(Error{ErrorCode::BadDescriptor, "rtti::heal_landmark"});
182
2/2
✓ Branch 13 → 14 taken 1 time.
✓ Branch 13 → 17 taken 49 times.
50 if (lm.window > rtti::MAX_HEAL_WINDOW)
183 1 return std::unexpected(Error{ErrorCode::BadDescriptor, "rtti::heal_landmark"});
184
2/2
✓ Branch 17 → 18 taken 48 times.
✓ Branch 17 → 19 taken 1 time.
49 const std::size_t stride = (lm.stride == 0) ? sizeof(std::uintptr_t) : lm.stride;
185
186 // 2. Single bounds computation. Unsigned arithmetic is wrap-defined; a
187 // nominal_offset that wraps the address space or lands outside the
188 // canonical user-mode window is rejected here, before any read. With
189 // a validated nominal_slot (>= MIN_VALID_PTR) and window <= MAX_HEAL_WINDOW
190 // (4096) << MIN_VALID_PTR (0x10000), the lo/hi derivations below
191 // cannot themselves underflow or wrap.
192 49 const std::uintptr_t nominal_slot = base.raw() + static_cast<std::uintptr_t>(lm.nominal_offset);
193
2/2
✓ Branch 22 → 23 taken 3 times.
✓ Branch 22 → 26 taken 46 times.
49 if (!DetourModKit::detail::is_plausible_ptr(nominal_slot))
194 3 return std::unexpected(Error{ErrorCode::BadDescriptor, "rtti::heal_landmark", nominal_slot});
195 46 std::uintptr_t lo = nominal_slot - lm.window;
196
1/2
✗ Branch 26 → 27 not taken.
✓ Branch 26 → 28 taken 46 times.
46 if (lo < rtti::detail::MIN_VALID_PTR)
197 ✗ lo = rtti::detail::MIN_VALID_PTR;
198 46 const std::uintptr_t hi = nominal_slot + lm.window;
199
200 46 rtti::PointeeType pt;
201
202 // 3. Nominal slot first. An exact-offset match short-circuits before the
203 // window scan, so while the nominal slot still matches, a same-typed
204 // neighbour in the window cannot force an ambiguity verdict. Once the
205 // nominal slot fails, the grid scan below reaches such neighbours and
206 // an equidistant pair still fails closed.
207
2/2
✓ Branch 29 → 30 taken 19 times.
✓ Branch 29 → 34 taken 27 times.
46 if (slot_matches(nominal_slot, lm, pt))
208 19 return make_hit(nominal_slot, base.raw(), pt);
209
210 // 4. Widened grid scan, nearest distance first. Candidate slots are
211 // congruent to nominal_slot modulo stride, so every probe stays pointer-aligned
212 // to the nominal slot. At each distance ring the -d and +d slots are
213 // both evaluated before deciding, so an equidistant tie is detected
214 // rather than silently resolved to one side.
215 27 for (std::size_t k = 1;; ++k)
216 {
217 134 const std::size_t step = k * stride;
218 134 const bool minus_in = step <= (nominal_slot - lo);
219 134 const bool plus_in = step <= (hi - nominal_slot);
220
3/4
✓ Branch 35 → 36 taken 13 times.
✓ Branch 35 → 39 taken 121 times.
✓ Branch 36 → 37 taken 13 times.
✗ Branch 36 → 39 not taken.
134 if (!minus_in && !plus_in)
221 13 break;
222
223 121 bool minus_match = false;
224 121 rtti::HealHit minus_hit{};
225
5/6
✓ Branch 39 → 40 taken 121 times.
✗ Branch 39 → 43 not taken.
✓ Branch 41 → 42 taken 3 times.
✓ Branch 41 → 43 taken 118 times.
✓ Branch 44 → 45 taken 3 times.
✓ Branch 44 → 48 taken 118 times.
121 if (minus_in && slot_matches(nominal_slot - step, lm, pt))
226 {
227 3 minus_match = true;
228 3 minus_hit = make_hit(nominal_slot - step, base.raw(), pt);
229 }
230
231 121 bool plus_match = false;
232 121 rtti::HealHit plus_hit{};
233
5/6
✓ Branch 48 → 49 taken 121 times.
✗ Branch 48 → 52 not taken.
✓ Branch 50 → 51 taken 12 times.
✓ Branch 50 → 52 taken 109 times.
✓ Branch 53 → 54 taken 12 times.
✓ Branch 53 → 57 taken 109 times.
121 if (plus_in && slot_matches(nominal_slot + step, lm, pt))
234 {
235 12 plus_match = true;
236 12 plus_hit = make_hit(nominal_slot + step, base.raw(), pt);
237 }
238
239 // A uniquely nearest match heals; an equidistant +d/-d pair is the irreducible ambiguity and fails
240 // closed.
241
4/4
✓ Branch 57 → 58 taken 3 times.
✓ Branch 57 → 62 taken 118 times.
✓ Branch 58 → 59 taken 1 time.
✓ Branch 58 → 62 taken 2 times.
121 if (minus_match && plus_match)
242 1 return std::unexpected(Error{ErrorCode::HealAmbiguous, "rtti::heal_landmark", nominal_slot});
243
2/2
✓ Branch 62 → 63 taken 2 times.
✓ Branch 62 → 64 taken 118 times.
120 if (minus_match)
244 2 return minus_hit;
245
2/2
✓ Branch 64 → 65 taken 11 times.
✓ Branch 64 → 66 taken 107 times.
118 if (plus_match)
246 11 return plus_hit;
247 107 }
248
249 13 return std::unexpected(Error{ErrorCode::HealNoMatch, "rtti::heal_landmark", nominal_slot});
250 }
251 } // anonymous namespace
252
253 426 Result<void> rtti::identify_pointee_typed(Address slot_addr, PointeeType &out) noexcept
254 {
255
2/2
✓ Branch 3 → 4 taken 4 times.
✓ Branch 3 → 8 taken 422 times.
426 if (slot_addr.raw() < detail::MIN_VALID_PTR)
256 4 return std::unexpected(Error{ErrorCode::BadSlotAddress, "rtti::identify_pointee", slot_addr.raw()});
257
258 422 const auto slot_opt = DetourModKit::detail::guarded_read<std::uintptr_t>(slot_addr.raw());
259
6/6
✓ Branch 11 → 12 taken 419 times.
✓ Branch 11 → 14 taken 3 times.
✓ Branch 13 → 14 taken 307 times.
✓ Branch 13 → 15 taken 112 times.
✓ Branch 16 → 17 taken 310 times.
✓ Branch 16 → 21 taken 112 times.
422 if (!slot_opt || *slot_opt < detail::MIN_VALID_PTR)
260 310 return std::unexpected(Error{ErrorCode::UnreadableSlot, "rtti::identify_pointee", slot_addr.raw()});
261 112 const std::uintptr_t slot_val = *slot_opt;
262
263 112 detail::ColSite site;
264 112 bool was_pointer = false;
265 112 std::uintptr_t object_base = 0;
266 112 std::uintptr_t vtable = 0;
267
268 // Resolve the candidate vtable's owning-module span once and reuse it across both shape attempts when the
269 // second candidate lives in the same module. resolve_col_site's single-argument overload calls the live-module
270 // resolver (a GetModuleHandleExW loader-lock acquisition) on every call, so the two-attempt probe below
271 // otherwise takes the loader lock up to twice per slot. The common direct-object case (an object's vtable and
272 // its first virtual function both live in the class-defining module) then costs a single acquisition. A
273 // genuinely cross-module second candidate (identify_pointee resolves an object whose vtable lives in a
274 // different DLL than the struct) still falls back to a fresh module_of, so the cross-DLL capability is
275 // preserved rather than regressed.
276 112 DetourModKit::detail::ModuleSpan first_span;
277 112 bool first_span_resolved = false;
278
279 // Pointer-to-object first: treat slot_val as a pointer to an object and try to resolve the pointee's vtable
280 // (*slot_val). A direct object would read its own first vtable entry here, which practically never satisfies
281 // the COL signature + pSelf cross-check, so this ordering does not misclassify real direct objects.
282 112 const auto vt2_opt = DetourModKit::detail::guarded_read<std::uintptr_t>(slot_val);
283
6/6
✓ Branch 24 → 25 taken 103 times.
✓ Branch 24 → 28 taken 9 times.
✓ Branch 26 → 27 taken 73 times.
✓ Branch 26 → 28 taken 30 times.
✓ Branch 29 → 30 taken 73 times.
✓ Branch 29 → 39 taken 39 times.
112 if (vt2_opt && *vt2_opt >= detail::MIN_VALID_PTR)
284 {
285 73 first_span = DetourModKit::detail::module_span(DetourModKit::detail::live_module_region(Address{*vt2_opt}));
286 73 first_span_resolved = true;
287
1/2
✓ Branch 36 → 37 taken 73 times.
✗ Branch 36 → 39 not taken.
73 if (detail::resolve_col_site(*vt2_opt, first_span, site))
288 {
289 73 was_pointer = true;
290 73 object_base = slot_val;
291 73 vtable = *vt2_opt;
292 }
293 }
294 // Else direct object base: the slot itself is the object, its value is the vtable. Pinned to ground truth: the
295 // object base is the slot ADDRESS, the vtable is the value READ at it (not a second deref).
296
2/2
✓ Branch 39 → 40 taken 39 times.
✓ Branch 39 → 54 taken 73 times.
112 if (vtable == 0)
297 {
298 // Reuse the first candidate's span when it also owns slot_val (the common same-module case); otherwise
299 // resolve slot_val's module afresh via the self-resolving overload (the cross-module fallback).
300
0/2
✗ Branch 42 → 43 not taken.
✗ Branch 42 → 45 not taken.
✗ const bool resolved = (first_span_resolved && first_span.contains(slot_val))
301
1/2
✗ Branch 40 → 41 not taken.
✓ Branch 40 → 45 taken 39 times.
39 ? detail::resolve_col_site(slot_val, first_span, site)
302 39 : detail::resolve_col_site(slot_val, site);
303
2/2
✓ Branch 47 → 48 taken 28 times.
✓ Branch 47 → 50 taken 11 times.
39 if (resolved)
304 {
305 28 was_pointer = false;
306 28 object_base = slot_addr.raw();
307 28 vtable = slot_val;
308 }
309 else
310 {
311 11 return std::unexpected(Error{ErrorCode::NoRtti, "rtti::identify_pointee", slot_addr.raw()});
312 }
313 }
314
315 // Read the name into the output buffer through the same page-bounded copy the forward walker uses. A faulted or
316 // empty name is a non-resolution. site.module_end clamps the copy to the vtable's owning module so a NUL-less
317 // edge-of-module name cannot over-read into an adjacent image.
318 const std::size_t name_len =
319 101 detail::read_name_seh(site.name_addr, out.name_buf, sizeof(out.name_buf), site.module_end);
320
1/2
✗ Branch 55 → 56 not taken.
✓ Branch 55 → 60 taken 101 times.
101 if (name_len == 0)
321 ✗ return std::unexpected(Error{ErrorCode::NoRtti, "rtti::identify_pointee", slot_addr.raw()});
322
323 // read_name_seh returns a boundary-truncated prefix when the name runs to the owning module's end without a NUL
324 // (it fills accum_cap == module_end - name_addr and appends its own output terminator). A name with no
325 // in-module terminator is not a confident identity: a forged descriptor whose non-terminated bytes equal a
326 // landmark's expected string would otherwise pass slot_matches's byte-exact pt.name() compare. Require the
327 // source terminator to sit strictly inside module_end. A genuine name found its NUL below accum_cap, so
328 // name_addr + name_len stays below the boundary, while a boundary-truncated name lands exactly on it.
329
2/4
✓ Branch 60 → 61 taken 101 times.
✗ Branch 60 → 66 not taken.
✗ Branch 61 → 62 not taken.
✓ Branch 61 → 66 taken 101 times.
101 if (site.module_end != 0 && site.name_addr + name_len >= site.module_end)
330 ✗ return std::unexpected(Error{ErrorCode::NoRtti, "rtti::identify_pointee", slot_addr.raw()});
331
332 101 out.vtable = Address{vtable};
333 101 out.col_addr = Address{site.col_addr};
334 101 out.td_addr = Address{site.td_addr};
335 101 out.name_addr = Address{site.name_addr};
336 101 out.object_base = Address{object_base};
337 101 out.col_offset = site.col_offset;
338 101 out.pointer_value = Address{slot_val};
339 101 out.was_pointer = was_pointer;
340 101 out.name_len = static_cast<std::uint16_t>(name_len);
341
342 // Complete object with underflow clamp: a garbage or forged col_offset larger than object_base must not wrap
343 // the address; report object_base itself in that (non-physical) case.
344
1/2
✓ Branch 72 → 73 taken 101 times.
✗ Branch 72 → 74 not taken.
101 out.complete_obj = Address{(object_base < site.col_offset) ? object_base : object_base - site.col_offset};
345 101 return {};
346 }
347
348 410 bool rtti::identify_pointee_type(Address slot_addr, PointeeType &out) noexcept
349 {
350 // The bool primitive is exactly has_value() over the typed core: one probe, one prelude walk, one
351 // implementation. Callers that need the WHY of a miss use identify_pointee_typed / identify_pointee_type_or.
352 410 return identify_pointee_typed(slot_addr, out).has_value();
353 }
354
355 6 std::size_t rtti::reverse_scan_block(
356 Address start,
357 std::size_t slot_count,
358 std::vector<LabeledSlot> &out,
359 std::size_t stride
360 ) noexcept
361 {
362
3/6
✓ Branch 3 → 4 taken 6 times.
✗ Branch 3 → 5 not taken.
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 6 times.
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 6 times.
6 if (start.raw() < detail::MIN_VALID_PTR || slot_count == 0)
363 ✗ return 0;
364
1/2
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 6 times.
6 if (stride == 0)
365 ✗ stride = sizeof(std::uintptr_t);
366
367 // Overflow guard mirroring find_in_pointer_table: reject a span that overflows size_t or wraps the address
368 // space.
369
2/2
✓ Branch 11 → 12 taken 1 time.
✓ Branch 11 → 13 taken 5 times.
6 if (slot_count > SIZE_MAX / stride)
370 1 return 0;
371 5 const std::uintptr_t start_raw = start.raw();
372 5 const std::uintptr_t span = static_cast<std::uintptr_t>(slot_count * stride);
373
1/2
✗ Branch 14 → 15 not taken.
✓ Branch 14 → 16 taken 5 times.
5 if (start_raw + span < start_raw)
374 ✗ return 0;
375
376 5 std::size_t added = 0;
377 5 PointeeType pt;
378
2/2
✓ Branch 25 → 17 taken 13 times.
✓ Branch 25 → 26 taken 5 times.
18 for (std::size_t i = 0; i < slot_count; ++i)
379 {
380 13 const std::uintptr_t slot_addr = start_raw + i * stride;
381
2/2
✓ Branch 19 → 20 taken 3 times.
✓ Branch 19 → 21 taken 10 times.
13 if (!identify_pointee_type(Address{slot_addr}, pt))
382 3 continue;
383 try
384 {
385
1/2
✓ Branch 22 → 23 taken 10 times.
✗ Branch 22 → 29 not taken.
10 out.push_back(LabeledSlot{Address{slot_addr}, i, pt});
386 }
387 ✗ catch (...)
388 {
389 // A reallocation failure must not escape the noexcept boundary;
390 // stop and report the slots already appended.
391 ✗ return added;
392 ✗ }
393 10 ++added;
394 }
395 5 return added;
396 }
397
398 2 std::size_t rtti::reverse_scan_block_bytes(
399 Address start,
400 std::size_t byte_len,
401 std::vector<LabeledSlot> &out,
402 std::size_t stride
403 ) noexcept
404 {
405
2/2
✓ Branch 2 → 3 taken 1 time.
✓ Branch 2 → 4 taken 1 time.
2 if (stride == 0)
406 1 stride = sizeof(std::uintptr_t);
407 2 return reverse_scan_block(start, byte_len / stride, out, stride);
408 }
409
410 42 Result<rtti::HealHit> rtti::heal_landmark(const Landmark &lm) noexcept
411 {
412 42 return heal_from(lm, lm.base);
413 }
414
415 Result<rtti::FingerprintHit>
416 14 rtti::solve_fingerprint(Address base, std::span<const Landmark> fp, std::size_t window_bytes) noexcept
417 {
418 // Validation. No memory is touched until a delta is probed below.
419
2/2
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 8 taken 13 times.
14 if (base.raw() < detail::MIN_VALID_PTR)
420 1 return std::unexpected(Error{ErrorCode::BadDescriptor, "rtti::solve_fingerprint", base.raw()});
421
6/6
✓ Branch 9 → 10 taken 12 times.
✓ Branch 9 → 12 taken 1 time.
✓ Branch 11 → 12 taken 1 time.
✓ Branch 11 → 13 taken 11 times.
✓ Branch 14 → 15 taken 2 times.
✓ Branch 14 → 18 taken 11 times.
13 if (fp.empty() || fp.size() > MAX_FINGERPRINT_LANDMARKS)
422 2 return std::unexpected(Error{ErrorCode::BadDescriptor, "rtti::solve_fingerprint"});
423
2/2
✓ Branch 18 → 19 taken 1 time.
✓ Branch 18 → 22 taken 10 times.
11 if (window_bytes > MAX_HEAL_WINDOW)
424 1 return std::unexpected(Error{ErrorCode::BadDescriptor, "rtti::solve_fingerprint"});
425
426 10 std::size_t required_count = 0;
427
2/2
✓ Branch 43 → 23 taken 29 times.
✓ Branch 43 → 44 taken 9 times.
38 for (std::size_t i = 0; i < fp.size(); ++i)
428 {
429
1/2
✗ Branch 25 → 26 not taken.
✓ Branch 25 → 29 taken 29 times.
29 if (!descriptor_ok(fp[i]))
430 ✗ return std::unexpected(Error{ErrorCode::BadDescriptor, "rtti::solve_fingerprint"});
431
2/2
✓ Branch 37 → 30 taken 29 times.
✓ Branch 37 → 38 taken 28 times.
57 for (std::size_t j = 0; j < i; ++j)
432 {
433
2/2
✓ Branch 32 → 33 taken 1 time.
✓ Branch 32 → 36 taken 28 times.
29 if (fp[j].nominal_offset == fp[i].nominal_offset)
434 1 return std::unexpected(Error{ErrorCode::BadDescriptor, "rtti::solve_fingerprint"});
435 }
436
2/2
✓ Branch 39 → 40 taken 25 times.
✓ Branch 39 → 41 taken 3 times.
28 if (fp[i].required)
437 25 ++required_count;
438 }
439 // A template with no required landmark cannot fail closed against a dense region, so it is rejected rather than
440 // guessed.
441
2/2
✓ Branch 44 → 45 taken 1 time.
✓ Branch 44 → 48 taken 8 times.
9 if (required_count == 0)
442 1 return std::unexpected(Error{ErrorCode::BadDescriptor, "rtti::solve_fingerprint"});
443
444 // Enumerate uniform deltas in [-window, +window] stepping by pointer size (real-world layout shifts are
445 // pointer-granular). A delta is a candidate only when it satisfies every required landmark; among candidates
446 // the most optional hits wins. A tie for the top optional score latches HealAmbiguous (fail closed) only when
447 // it is between two nonzero deltas; a zero-drift candidate (delta 0, the anchor still validating) wins a tie
448 // outright, since the object is exactly where the caller anchored.
449 8 constexpr std::ptrdiff_t step = static_cast<std::ptrdiff_t>(sizeof(std::uintptr_t));
450 8 const std::ptrdiff_t w = static_cast<std::ptrdiff_t>(window_bytes);
451
452 8 PointeeType pt;
453 8 bool have_best = false;
454 8 bool tie = false;
455 8 std::ptrdiff_t best_delta = 0;
456 8 std::size_t best_optional = 0;
457
458 72 const auto eval_delta = [&](std::ptrdiff_t delta) noexcept
459 {
460 72 std::size_t opt_hits = 0;
461
2/2
✓ Branch 27 → 4 taken 93 times.
✓ Branch 27 → 28 taken 10 times.
175 for (const Landmark &lm : fp)
462 {
463 const std::uintptr_t addr =
464 93 base.raw() + static_cast<std::uintptr_t>(lm.nominal_offset) + static_cast<std::uintptr_t>(delta);
465
3/4
✓ Branch 8 → 9 taken 93 times.
✗ Branch 8 → 12 not taken.
✓ Branch 10 → 11 taken 30 times.
✓ Branch 10 → 12 taken 63 times.
93 const bool ok = DetourModKit::detail::is_plausible_ptr(addr) && slot_matches(addr, lm, pt);
466
2/2
✓ Branch 13 → 14 taken 91 times.
✓ Branch 13 → 16 taken 2 times.
93 if (lm.required)
467 {
468 // A missing required landmark disqualifies this delta outright; abandon it without scoring the
469 // rest.
470
2/2
✓ Branch 14 → 15 taken 62 times.
✓ Branch 14 → 18 taken 29 times.
91 if (!ok)
471 62 return;
472 }
473
2/2
✓ Branch 16 → 17 taken 1 time.
✓ Branch 16 → 18 taken 1 time.
2 else if (ok)
474 {
475 1 ++opt_hits;
476 }
477 }
478
479 // Every required landmark matched: this delta is a candidate.
480
3/4
✓ Branch 28 → 29 taken 3 times.
✓ Branch 28 → 30 taken 7 times.
✗ Branch 29 → 30 not taken.
✓ Branch 29 → 31 taken 3 times.
10 if (!have_best || opt_hits > best_optional)
481 {
482 7 have_best = true;
483 7 best_optional = opt_hits;
484 7 best_delta = delta;
485 7 tie = false;
486 }
487
4/4
✓ Branch 31 → 32 taken 2 times.
✓ Branch 31 → 34 taken 1 time.
✓ Branch 32 → 33 taken 1 time.
✓ Branch 32 → 34 taken 1 time.
3 else if (opt_hits == best_optional && best_delta != 0)
488 {
489 // An equal-score tie is genuine ambiguity only between two nonzero deltas: neither candidate sits at
490 // the caller's anchor, so there is no principled way to pick and the solve fails closed. When the
491 // incumbent is the zero-drift solution (delta 0 is probed first below, so best_delta == 0 means it
492 // already satisfies every required landmark), the anchor itself still validates: honour it. That is the
493 // "no drift: the object is exactly where the caller anchored" reading, and it correctly resolves an
494 // array of same-typed objects to element 0 (the one at base) instead of refusing because a sibling at
495 // +stride matches equally. A strictly higher optional score at any delta still wins above; only an
496 // equal-score tie against the zero-drift incumbent is suppressed here.
497 1 tie = true;
498 }
499 8 };
500
501 // Iterate magnitudes 0, +step, -step, +2*step, ... so the scan is nearest-first; the decision itself is
502 // score-based, not distance-based.
503
2/2
✓ Branch 53 → 49 taken 40 times.
✓ Branch 53 → 54 taken 8 times.
48 for (std::ptrdiff_t m = 0; m <= w; m += step)
504 {
505 40 eval_delta(m);
506
2/2
✓ Branch 50 → 51 taken 32 times.
✓ Branch 50 → 52 taken 8 times.
40 if (m != 0)
507 32 eval_delta(-m);
508 }
509
510
2/2
✓ Branch 54 → 55 taken 1 time.
✓ Branch 54 → 59 taken 7 times.
8 if (!have_best)
511 1 return std::unexpected(Error{ErrorCode::HealNoMatch, "rtti::solve_fingerprint", base.raw()});
512
2/2
✓ Branch 59 → 60 taken 1 time.
✓ Branch 59 → 64 taken 6 times.
7 if (tie)
513 1 return std::unexpected(Error{ErrorCode::HealAmbiguous, "rtti::solve_fingerprint", base.raw()});
514 6 return FingerprintHit{best_delta, required_count, best_optional};
515 }
516
517 3 std::size_t rtti::heal_report(std::span<const Landmark> landmarks, std::span<DriftEntry> out) noexcept
518 {
519
1/2
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 3 times.
3 const std::size_t written = (landmarks.size() < out.size()) ? landmarks.size() : out.size();
520
2/2
✓ Branch 21 → 8 taken 5 times.
✓ Branch 21 → 22 taken 3 times.
8 for (std::size_t i = 0; i < written; ++i)
521 {
522 5 const Landmark &landmark = landmarks[i];
523 5 DriftEntry &entry = out[i];
524 // Start from a clean entry so a failed heal cannot expose stale healed_offset/delta from a reused
525 // (non-zeroed) output buffer.
526 5 entry = DriftEntry{};
527 5 entry.name = landmark.expected_mangled;
528 5 entry.nominal_offset = landmark.nominal_offset;
529
530 5 const auto heal = heal_landmark(landmark);
531
2/2
✓ Branch 13 → 14 taken 4 times.
✓ Branch 13 → 18 taken 1 time.
5 if (heal)
532 {
533 4 entry.ok = true;
534 4 entry.healed_offset = heal->healed_offset;
535 // delta is the realised layout shift: 0 when the field did not move, signed when it did. It is the
536 // reported layout shift, derived purely from the existing heal result. Saturation preserves the
537 // direction of a difference that does not fit in ptrdiff_t.
538 4 entry.delta = rtti::detail::saturating_sub(heal->healed_offset, landmark.nominal_offset);
539 }
540 else
541 {
542 // ok stays false; healed_offset and delta stay 0 (valid only when ok).
543 1 entry.error = heal.error().code;
544 }
545 }
546 3 return written;
547 }
548
549 struct rtti::HealScheduler::Impl
550 {
551 // One independently-latched heal group: its own retry countdown and its own success latch, so a group whose
552 // target comes up late retries on the interval without freezing any sibling group.
553 struct Group
554 {
555 Work work;
556 Gate gate;
557 bool latched = false;
558 std::uint32_t frames_until_retry = 0;
559 };
560
561 HealConfig config;
562 // The per-scheduler "layout has drifted" latch, claimed by CAS so exactly one Warning is emitted across every
563 // group of this scheduler even when several fields moved on the same frame.
564 std::atomic<bool> drift_warned{false};
565 std::vector<Group> groups;
566 // Groups registered from within a running tick() (a callback calling add_group) are staged here and merged into
567 // `groups` after the scan loop, so add_group can never reallocate `groups` while tick's range-for holds a
568 // reference into it.
569 std::vector<Group> pending;
570 // Re-entrancy depth of tick(): 0 outside a scan and 1 while one is in flight. A rejected nested tick never
571 // changes the outer scan's in-flight state.
572 unsigned tick_depth = 0;
573
574 // Moves every deferred group into `groups`. Callable only while no scan is in flight, since it may reallocate
575 // `groups`. insert() reserves once up front (so on OOM it throws before moving any element, leaving `pending`
576 // intact for the next attempt) and then move-constructs each element (a std::move_only_function move is
577 // noexcept), so a failed adoption loses no work and keeps tick() noexcept.
578 890 void adopt_pending() noexcept
579 {
580
2/2
✓ Branch 3 → 4 taken 875 times.
✓ Branch 3 → 5 taken 15 times.
890 if (pending.empty())
581 875 return;
582 try
583 {
584
4/6
✓ Branch 6 → 7 taken 15 times.
✗ Branch 6 → 18 not taken.
✓ Branch 8 → 9 taken 15 times.
✗ Branch 8 → 18 not taken.
✓ Branch 13 → 14 taken 5 times.
✓ Branch 13 → 16 taken 10 times.
40 groups.insert(
585 25 groups.end(),
586 std::make_move_iterator(pending.begin()),
587 std::make_move_iterator(pending.end())
588 );
589 5 pending.clear();
590 }
591 10 catch (...)
592 {
593 // Allocation failed; leave `pending` untouched so the deferred groups are retried on the next tick.
594 10 }
595 }
596 };
597
598 25 Result<rtti::HealScheduler> rtti::HealScheduler::start(HealConfig config) noexcept
599 {
600 // A zero interval would divide-by-nothing the retry budget (every tick is a scan), which is a caller mistake,
601 // not a valid cadence; reject it up front rather than silently reinterpret it.
602
4/4
✓ Branch 2 → 3 taken 24 times.
✓ Branch 2 → 4 taken 1 time.
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 7 taken 23 times.
25 if (config.interval_frames == 0 || config.drift_warn_threshold < 0)
603 2 return std::unexpected(Error{ErrorCode::InvalidArg, "rtti::HealScheduler::start"});
604 try
605 {
606
1/2
✓ Branch 7 → 8 taken 23 times.
✗ Branch 7 → 19 not taken.
23 auto impl = std::make_unique<Impl>();
607 23 impl->config = config;
608 23 return HealScheduler{std::move(impl)};
609 23 }
610 ✗ catch (...)
611 {
612 ✗ return std::unexpected(Error{ErrorCode::OutOfMemory, "rtti::HealScheduler::start"});
613 ✗ }
614 }
615
616 46 rtti::HealScheduler::HealScheduler(std::unique_ptr<Impl> impl) noexcept : m_impl(std::move(impl)) {}
617 24 rtti::HealScheduler::HealScheduler(HealScheduler &&) noexcept = default;
618 ✗ rtti::HealScheduler &rtti::HealScheduler::operator=(HealScheduler &&) noexcept = default;
619 47 rtti::HealScheduler::~HealScheduler() noexcept = default;
620
621 348 Result<void> rtti::HealScheduler::add_group(Work work, Gate gate) noexcept
622 {
623
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 348 times.
348 if (!m_impl)
624 ✗ return {};
625 // A group with no heal work can never resolve; ignore an empty callback rather than let it reach tick(), where
626 // invoking an empty std::move_only_function would be undefined behavior.
627
1/2
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 348 times.
348 if (!work)
628 ✗ return {};
629 // Defer a group added from within a running tick (a work/gate callback re-entering add_group) so tick's
630 // range-for reference into `groups` is never invalidated by a reallocation mid-iteration; it starts scanning on
631 // the next tick.
632
2/2
✓ Branch 9 → 10 taken 322 times.
✓ Branch 9 → 12 taken 26 times.
348 std::vector<Impl::Group> &target = m_impl->tick_depth != 0 ? m_impl->pending : m_impl->groups;
633 try
634 {
635
6/10
✓ Branch 20 → 21 taken 346 times.
✓ Branch 20 → 28 taken 2 times.
✗ Branch 22 → 23 not taken.
✓ Branch 22 → 24 taken 346 times.
✗ Branch 24 → 25 not taken.
✓ Branch 24 → 26 taken 346 times.
✗ Branch 30 → 31 not taken.
✓ Branch 30 → 32 taken 2 times.
✗ Branch 33 → 34 not taken.
✓ Branch 33 → 35 taken 2 times.
1050 target.push_back(Impl::Group{std::move(work), std::move(gate), false, 0});
636 }
637 2 catch (...)
638 {
639 // push_back's strong guarantee leaves the vector unchanged, so a failed registration commits no state.
640 2 return std::unexpected(Error{ErrorCode::OutOfMemory, "rtti::HealScheduler::add_group"});
641 2 }
642 346 return {};
643 }
644
645 450 void rtti::HealScheduler::tick() noexcept
646 {
647
2/2
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 449 times.
450 if (!m_impl)
648 1 return;
649 // Reject a re-entrant tick (a work or gate callback calling tick() again on this scheduler). Running the inner
650 // scan would clear the in-flight state on return, after which the outer scan's range-for still holds a
651 // reference into `groups` and a subsequent add_group would push directly into `groups`, reallocating it
652 // mid-iteration. A nested tick is a no-op; the groups it would have scanned run on the next outer tick. Because
653 // the reject returns before the depth is bumped, it cannot clear the outer scan's in-flight marker.
654
2/2
✓ Branch 6 → 7 taken 4 times.
✓ Branch 6 → 8 taken 445 times.
449 if (m_impl->tick_depth != 0)
655 4 return;
656 // Retry an adoption a previous tick could not complete, before the depth bump so the retry runs with no scan in
657 // flight. A group deferred during tick N therefore scans on tick N + 1 even when tick N's exit adoption hit
658 // OOM; adopting only at exit would spend tick N + 1 moving the queue and not scan it until N + 2, so a
659 // consumer that ticks only once more would adopt the heal work and stop without ever running it.
660 445 m_impl->adopt_pending();
661 445 ++m_impl->tick_depth;
662
2/2
✓ Branch 45 → 14 taken 771 times.
✓ Branch 45 → 46 taken 445 times.
1661 for (Impl::Group &group : m_impl->groups)
663 {
664
2/2
✓ Branch 16 → 17 taken 249 times.
✓ Branch 16 → 18 taken 522 times.
771 if (group.latched)
665 416 continue;
666
667 // Silent pre-gate, evaluated BEFORE the interval countdown: a target that is not constructed yet is polled
668 // cheaply every frame and skipped without spending the retry budget or logging. A throwing gate is treated
669 // as "not ready".
670
2/2
✓ Branch 19 → 20 taken 101 times.
✓ Branch 19 → 24 taken 421 times.
522 if (group.gate)
671 {
672 101 bool ready = false;
673 try
674 {
675
1/2
✓ Branch 20 → 21 taken 101 times.
✗ Branch 20 → 50 not taken.
101 ready = group.gate();
676 }
677 ✗ catch (...)
678 {
679 ✗ ready = false;
680 ✗ }
681
2/2
✓ Branch 22 → 23 taken 100 times.
✓ Branch 22 → 24 taken 1 time.
101 if (!ready)
682 100 continue;
683 }
684
685 // Fixed-interval countdown. The scan frame itself does not decrement: after a scan the counter is reset to
686 // interval_frames and the next interval_frames ticks are skips, so scans land on frames 0, interval+1,
687 // 2*(interval)+2, and so on. The cadence is fixed, never a geometric backoff.
688
2/2
✓ Branch 24 → 25 taken 67 times.
✓ Branch 24 → 26 taken 355 times.
422 if (group.frames_until_retry > 0)
689 {
690 67 --group.frames_until_retry;
691 67 continue;
692 }
693 355 group.frames_until_retry = m_impl->config.interval_frames;
694
695 355 HealRun run{m_impl->config, m_impl->drift_warned};
696 355 bool resolved = false;
697 try
698 {
699
1/2
✓ Branch 30 → 31 taken 355 times.
✗ Branch 30 → 53 not taken.
355 resolved = group.work(run);
700 }
701 ✗ catch (...)
702 {
703 // A throwing work callback is treated as "did not resolve this frame"; the group retries next interval.
704 ✗ resolved = false;
705 ✗ }
706
2/2
✓ Branch 32 → 33 taken 342 times.
✓ Branch 32 → 34 taken 13 times.
355 if (resolved)
707 342 group.latched = true;
708 }
709
710 // The scan loop is done; drop the in-flight depth, then adopt any groups a callback deferred while ticking.
711 445 --m_impl->tick_depth;
712 445 m_impl->adopt_pending();
713 }
714
715 27 bool rtti::HealScheduler::all_resolved() const noexcept
716 {
717
2/2
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 26 times.
27 if (!m_impl)
718 1 return true;
719 // A deferred group is registered from the moment add_group returns, so completion cannot be claimed while the
720 // adoption queue still holds work: reporting only over `groups` would answer true for a scheduler whose
721 // end-of-tick adoption ran out of memory and is still holding live, unlatched heal work.
722
2/2
✓ Branch 7 → 8 taken 5 times.
✓ Branch 7 → 9 taken 21 times.
26 if (!m_impl->pending.empty())
723 5 return false;
724
2/2
✓ Branch 25 → 12 taken 215 times.
✓ Branch 25 → 26 taken 15 times.
251 for (const Impl::Group &group : m_impl->groups)
725 {
726
2/2
✓ Branch 14 → 15 taken 6 times.
✓ Branch 14 → 16 taken 209 times.
215 if (!group.latched)
727 6 return false;
728 }
729 15 return true;
730 }
731
732 1 const rtti::HealConfig &rtti::HealScheduler::config() const noexcept
733 {
734 // A moved-from scheduler is inert (m_impl == nullptr), the same contract tick / add_group / all_resolved honor.
735 // config() returns a reference, so it cannot no-op; hand back a reference to a static default rather than
736 // dereferencing null, keeping the accessor safe on an inert instance too.
737
1/2
✓ Branch 3 → 4 taken 1 time.
✗ Branch 3 → 5 not taken.
1 if (!m_impl)
738 {
739 static const HealConfig inert_config{};
740 1 return inert_config;
741 }
742 ✗ return m_impl->config;
743 }
744
745 1 void rtti::HealRun::warn_drift_once(std::string_view label, std::ptrdiff_t delta) noexcept
746 {
747 // Compare magnitudes in the unsigned domain: the naive (delta < 0) ? -delta : delta is undefined at
748 // PTRDIFF_MIN, whose negation is not representable. A drift delta can reach that bound for a caller-supplied
749 // nominal, so route both operands through the PTRDIFF_MIN-safe magnitude helper.
750 1 const std::uint64_t magnitude = rtti::detail::ptrdiff_magnitude(delta);
751
1/2
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 1 time.
1 if (magnitude <= rtti::detail::ptrdiff_magnitude(m_config.drift_warn_threshold))
752 ✗ return;
753 // CAS one-shot: the first drift to clear the latch emits the single actionable Warning. The recovered POINTER
754 // offsets self-healed, but the non-healable scalar/flag offsets in the same structs silently rode the same
755 // shift and need a human to re-verify. That is the actionable headline this one line carries.
756 1 bool expected = false;
757
1/2
✓ Branch 7 → 8 taken 1 time.
✗ Branch 7 → 11 not taken.
1 if (m_drift_warned.compare_exchange_strong(expected, true, std::memory_order_relaxed))
758 {
759 1 (void)log().try_log(
760 LogLevel::Warning,
761 "Self-heal: layout drifted (first change: {} by {:+#x}); pointer offsets recovered. "
762 "Re-verify non-healable scalars.",
763 label,
764 delta
765 );
766 }
767 }
768
769 6 Result<rtti::HealHit> rtti::HealRun::heal_into(
770 std::string_view label,
771 const Landmark &landmark,
772 Address base,
773 std::atomic<std::ptrdiff_t> &slot,
774 bool required
775 ) noexcept
776 {
777 // heal_from takes the base explicitly, so the landmark is not copied. That keeps heal_into allocation-free
778 // and truly noexcept even for a landmark whose owned name would not fit the small-string buffer.
779 6 Result<HealHit> result = heal_from(landmark, base);
780 6 Logger &logger = log();
781
2/2
✓ Branch 5 → 6 taken 3 times.
✓ Branch 5 → 26 taken 3 times.
6 if (result)
782 {
783 3 slot.store(result->healed_offset, std::memory_order_relaxed);
784 3 const std::ptrdiff_t delta = rtti::detail::saturating_sub(result->healed_offset, landmark.nominal_offset);
785
2/2
✓ Branch 17 → 18 taken 1 time.
✓ Branch 17 → 22 taken 2 times.
3 if (delta != 0)
786 {
787 1 warn_drift_once(label, delta);
788 1 (void)logger.try_log(
789 LogLevel::Info,
790 "Self-heal: {} moved {:+#x} ({:#x} -> {:#x})",
791 label,
792 delta,
793 1 landmark.nominal_offset,
794 1 result->healed_offset
795 );
796 }
797 else
798 {
799 2 (void)logger.try_log(
800 LogLevel::Debug,
801 "Self-heal: {} confirmed at nominal {:#x}",
802 label,
803 2 landmark.nominal_offset
804 );
805 }
806 3 return result;
807 }
808
809 // Fail closed: the slot keeps whatever nominal it was seeded with (untouched above). A required miss escalates
810 // to a Warning under WarnRequired; an optional or Quiet miss stays at Debug so a legitimately-absent target
811 // does not spam the log on the frames before it comes up.
812 3 const std::string_view reason = to_string(result.error().code);
813
1/4
✗ Branch 28 → 29 not taken.
✓ Branch 28 → 32 taken 3 times.
✗ Branch 29 → 30 not taken.
✗ Branch 29 → 32 not taken.
3 if (required && m_config.escalate == HealEscalation::WarnRequired)
814 {
815 ✗ (void)logger.try_log(
816 LogLevel::Warning,
817 "Self-heal: {} unresolved ({}); kept nominal {:#x} (re-author "
818 "if drifted)",
819 label,
820 reason,
821 ✗ landmark.nominal_offset
822 );
823 }
824 else
825 {
826 3 (void)logger.try_log(
827 LogLevel::Debug,
828 "Self-heal: {} not resolvable now ({}); keeping nominal {:#x}",
829 label,
830 reason,
831 3 landmark.nominal_offset
832 );
833 }
834 3 return result;
835 }
836
837 7 void rtti::HealedSlot::seed_nominal(std::ptrdiff_t nominal) noexcept
838 {
839 // A nominal that has never been confirmed: carry it with generation 0 and Unverified, so a consumer reading the
840 // slot before the first heal gets an explicit best-guess status rather than a Confirmed value it could trust.
841 7 publish(nominal, 0, OffsetValidity::Unverified);
842 7 }
843
844 20018 void rtti::HealedSlot::publish(std::ptrdiff_t value, std::uint64_t generation, OffsetValidity validity) noexcept
845 {
846
2/2
✓ Branch 2 → 3 taken 10006 times.
✓ Branch 2 → 5 taken 10012 times.
20018 if (validity == OffsetValidity::Confirmed)
847 {
848
2/2
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 6 taken 10005 times.
10006 if (generation == 0)
849 1 validity = OffsetValidity::Invalid;
850 }
851 else
852 {
853 10012 generation = 0;
854 }
855
856 // Single-producer seqlock write: bump the sequence to odd (write in progress), store the payload, bump to even
857 // (stable). The release fences pair with the consumer's acquire fence in load() so a reader either sees the
858 // whole new snapshot or retries - never a torn mix of an old and a new field.
859 20018 const std::uint32_t seq = m_seq.load(std::memory_order_relaxed);
860 20018 m_seq.store(seq + 1, std::memory_order_relaxed);
861 std::atomic_thread_fence(std::memory_order_release);
862 20018 m_value.store(value, std::memory_order_relaxed);
863 20018 m_generation.store(generation, std::memory_order_relaxed);
864 20018 m_validity.store(static_cast<std::uint8_t>(validity), std::memory_order_relaxed);
865 std::atomic_thread_fence(std::memory_order_release);
866 20018 m_seq.store(seq + 2, std::memory_order_relaxed);
867 20018 }
868
869 27592 rtti::HealedOffset rtti::HealedSlot::load() const noexcept
870 {
871 27592 constexpr std::size_t MAX_ATTEMPTS = 16;
872
2/2
✓ Branch 44 → 3 taken 197730 times.
✓ Branch 44 → 45 taken 9577 times.
207307 for (std::size_t attempt = 0; attempt < MAX_ATTEMPTS; ++attempt)
873 {
874 197730 const std::uint32_t seq1 = m_seq.load(std::memory_order_acquire);
875
2/2
✓ Branch 10 → 11 taken 167596 times.
✓ Branch 10 → 12 taken 30134 times.
197730 if ((seq1 & 1U) != 0U)
876 167596 continue; // A publish is in progress; retry once it completes (single producer, so this is brief).
877 30134 const std::ptrdiff_t value = m_value.load(std::memory_order_relaxed);
878 30134 const std::uint64_t generation = m_generation.load(std::memory_order_relaxed);
879 60268 const std::uint8_t validity = m_validity.load(std::memory_order_relaxed);
880 std::atomic_thread_fence(std::memory_order_acquire);
881
2/2
✓ Branch 41 → 42 taken 18015 times.
✓ Branch 41 → 43 taken 12119 times.
60268 if (m_seq.load(std::memory_order_relaxed) == seq1)
882 18015 return HealedOffset{value, generation, static_cast<OffsetValidity>(validity)};
883 }
884 9577 return {};
885 }
886
887 6 Result<std::ptrdiff_t> rtti::HealedSlot::authorized() const noexcept
888 {
889 6 const HealedOffset snap = load();
890
3/4
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 5 times.
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 8 taken 1 time.
6 if (snap.validity != OffsetValidity::Confirmed || snap.generation == 0)
891 5 return std::unexpected(Error{ErrorCode::OffsetNotConfirmed, "rtti::HealedSlot::authorized"});
892 1 return snap.value;
893 }
894
895 9 Result<std::ptrdiff_t> rtti::HealedSlot::authorized(std::uint64_t current_generation) const noexcept
896 {
897 9 const HealedOffset snap = load();
898
5/6
✓ Branch 3 → 4 taken 8 times.
✓ Branch 3 → 7 taken 1 time.
✓ Branch 4 → 5 taken 8 times.
✗ Branch 4 → 7 not taken.
✓ Branch 5 → 6 taken 7 times.
✓ Branch 5 → 7 taken 1 time.
9 if (snap.validity != OffsetValidity::Confirmed || snap.generation == 0 || current_generation == 0 ||
899
2/2
✓ Branch 6 → 7 taken 2 times.
✓ Branch 6 → 10 taken 5 times.
7 snap.generation != current_generation)
900 4 return std::unexpected(
901 4 Error{ErrorCode::OffsetNotConfirmed, "rtti::HealedSlot::authorized", snap.generation}
902 4 );
903 5 return snap.value;
904 }
905
906 7 Result<rtti::HealHit> rtti::HealRun::heal_into(
907 std::string_view label,
908 const Landmark &landmark,
909 Address base,
910 HealedSlot &slot,
911 bool required
912 ) noexcept
913 {
914 7 Result<HealHit> result = heal_from(landmark, base);
915 7 Logger &logger = log();
916
2/2
✓ Branch 5 → 6 taken 5 times.
✓ Branch 5 → 27 taken 2 times.
7 if (result)
917 {
918 // The vtable, unlike the live struct buffer, identifies the image whose RTTI established this layout. The
919 // token is captured across a re-established evidence walk so publication and evidence share one image.
920 5 const std::uint64_t generation = bracketed_generation(landmark, *result);
921
2/2
✓ Branch 8 → 9 taken 2 times.
✓ Branch 8 → 22 taken 3 times.
5 if (generation != 0)
922 {
923 2 slot.publish(result->healed_offset, generation, OffsetValidity::Confirmed);
924 const std::ptrdiff_t delta =
925 2 rtti::detail::saturating_sub(result->healed_offset, landmark.nominal_offset);
926
1/2
✗ Branch 13 → 14 not taken.
✓ Branch 13 → 18 taken 2 times.
2 if (delta != 0)
927 {
928 ✗ warn_drift_once(label, delta);
929 ✗ (void)logger.try_log(
930 LogLevel::Info,
931 "Self-heal: {} moved {:+#x} ({:#x} -> {:#x})",
932 label,
933 delta,
934 ✗ landmark.nominal_offset,
935 ✗ result->healed_offset
936 );
937 }
938 else
939 {
940 2 (void)logger.try_log(
941 LogLevel::Debug,
942 "Self-heal: {} confirmed at nominal {:#x}",
943 label,
944 2 landmark.nominal_offset
945 );
946 }
947 2 return result;
948 }
949 result =
950 3 std::unexpected(Error{ErrorCode::OffsetNotConfirmed, "rtti::HealRun::heal_into", result->vtable.raw()});
951 }
952
953 // Miss: keep the retained value (whatever nominal the slot was seeded with) but publish the validity a
954 // slot-only consumer needs: Invalid for a required field (do not consume) or Unverified for an optional one
955 // (best-guess only). A raw atomic slot leaves the dangerous nominal with no in-band signal that the required
956 // heal failed; this channel carries one, so a consumer reading only the slot can still fail closed.
957 5 const HealedOffset retained = slot.load();
958
2/2
✓ Branch 28 → 29 taken 4 times.
✓ Branch 28 → 30 taken 1 time.
5 slot.publish(retained.value, 0, required ? OffsetValidity::Invalid : OffsetValidity::Unverified);
959
960 5 const std::string_view reason = to_string(result.error().code);
961
4/4
✓ Branch 34 → 35 taken 4 times.
✓ Branch 34 → 38 taken 1 time.
✓ Branch 35 → 36 taken 3 times.
✓ Branch 35 → 38 taken 1 time.
5 if (required && m_config.escalate == HealEscalation::WarnRequired)
962 {
963 3 (void)logger.try_log(
964 LogLevel::Warning,
965 "Self-heal: {} unresolved ({}); kept nominal {:#x} (re-author "
966 "if drifted)",
967 label,
968 reason,
969 3 landmark.nominal_offset
970 );
971 }
972 else
973 {
974 2 (void)logger.try_log(
975 LogLevel::Debug,
976 "Self-heal: {} not resolvable now ({}); keeping nominal {:#x}",
977 label,
978 reason,
979 2 landmark.nominal_offset
980 );
981 }
982 5 return result;
983 }
984
985 ✗ void rtti::HealRun::note_drift(
986 std::string_view label,
987 std::ptrdiff_t nominal_offset,
988 std::ptrdiff_t healed_offset
989 ) noexcept
990 {
991 ✗ const std::ptrdiff_t delta = rtti::detail::saturating_sub(healed_offset, nominal_offset);
992 ✗ Logger &logger = log();
993
0/2
✗ Branch 4 → 5 not taken.
✗ Branch 4 → 8 not taken.
✗ if (delta != 0)
994 {
995 ✗ warn_drift_once(label, delta);
996 ✗ (void)logger.try_log(
997 LogLevel::Info,
998 "Self-heal: {} moved {:+#x} ({:#x} -> {:#x})",
999 label,
1000 delta,
1001 nominal_offset,
1002 healed_offset
1003 );
1004 }
1005 else
1006 {
1007 ✗ (void)logger.try_log(LogLevel::Debug, "Self-heal: {} confirmed at nominal {:#x}", label, nominal_offset);
1008 }
1009 ✗ }
1010 } // namespace DetourModKit
1011