GCC Code Coverage Report


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

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