GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 93.5% 29 / 0 / 31
Functions: 100.0% 6 / 0 / 6
Branches: 77.8% 14 / 0 / 18

include/DetourModKit/rtti_dissect.hpp
Line Branch Exec Source
1 #ifndef DETOURMODKIT_RTTI_DISSECT_HPP
2 #define DETOURMODKIT_RTTI_DISSECT_HPP
3
4 /**
5 * @file rtti_dissect.hpp
6 * @brief Reverse-direction RTTI dissection, self-healing offsets, and the frame-scheduled heal runner.
7 * @details Every non-scheduler entry point is noexcept and fails closed. Entry points reach foreign memory only
8 * through the guarded RTTI prelude. Matching uses exact MSVC-mangled bytes. Scope is x64 MSVC.
9 * @warning `[B-100]` Under the loader lock, call only a @ref HealedSlot read. Dissection and self-heal query the
10 * loader through RTTI, while scheduler setup allocates.
11 */
12
13 #include "DetourModKit/error.hpp"
14 #include "DetourModKit/rtti.hpp"
15
16 #include <atomic>
17 #include <concepts>
18 #include <cstddef>
19 #include <cstdint>
20 #include <functional>
21 #include <memory>
22 #include <span>
23 #include <string>
24 #include <string_view>
25 #include <vector>
26
27 namespace DetourModKit
28 {
29 namespace rtti
30 {
31 /**
32 * @brief Hard cap on a self-heal search radius (bytes per side). Bounds the worst-case probe count so an
33 * accidental SIZE_MAX window cannot hang.
34 */
35 inline constexpr std::size_t MAX_HEAL_WINDOW = 4096;
36
37 /**
38 * @brief Hard cap on the number of landmarks in one @ref solve_fingerprint template.
39 */
40 inline constexpr std::size_t MAX_FINGERPRINT_LANDMARKS = 32;
41
42 /**
43 * @struct PointeeType
44 * @brief Result of reverse-identifying the object behind one slot.
45 * @details Self-contained: @ref name_buf holds an inline copy of the mangled name, so no field points into a
46 * transient buffer. The struct is ~1 KiB. The self-heal path reuses one stack instance.
47 */
48 struct PointeeType
49 {
50 /// Resolved vtable pointer.
51 Address vtable{};
52 /// COL the vtable points back to.
53 Address col_addr{};
54 /// TypeDescriptor base.
55 Address td_addr{};
56 /// Mangled-name buffer (td_addr + 0x10).
57 Address name_addr{};
58 /// Start of the resolved (sub)object.
59 Address object_base{};
60 /// object_base - col_offset (underflow-clamped).
61 Address complete_obj{};
62 /// Raw qword read at the probed slot.
63 Address pointer_value{};
64 /// COL.offset (+0x04): this vtable's offset in the complete object.
65 std::uint32_t col_offset = 0;
66 /// true when the slot held a pointer-to-object (deref'd once).
67 bool was_pointer = false;
68 /// Length of the mangled name in @ref name_buf.
69 std::uint16_t name_len = 0;
70 /// NUL-terminated mangled name.
71 char name_buf[MAX_TYPE_NAME_LEN + 1] = {};
72
73 /// Non-owning view of the mangled name held in @ref name_buf.
74 91 [[nodiscard]] std::string_view name() const noexcept { return std::string_view(name_buf, name_len); }
75 };
76
77 /**
78 * @brief Reverse-RTTI-identify the object a pointer slot refers to.
79 * @details Tries pointer-to-object first, then treats the slot as a direct object base. Either shape must
80 * pass the verified COL prelude; @c was_pointer reports the winning shape without imposing module
81 * locality.
82 * @param slot_addr Address of the pointer-sized slot to probe.
83 * @param out Receives the identification on success. On a false return its contents are unspecified, so callers
84 * must check the return before reading it.
85 * @return true when a real RTTI type resolved, false on a null/low slot, an unreadable slot, or neither shape
86 * resolving.
87 */
88 [[nodiscard]] bool identify_pointee_type(Address slot_addr, PointeeType &out) noexcept;
89
90 /**
91 * @brief Typed form of @ref identify_pointee_type.
92 * @details @ref identify_pointee_type is exactly @c has_value() over this. The Error code is
93 * @ref ErrorCode::BadSlotAddress (null/low slot), @ref ErrorCode::UnreadableSlot (faulted or null/low
94 * slot value), or @ref ErrorCode::NoRtti (neither shape carried a verifiable COL). Use this form when
95 * the reason for a miss matters.
96 * @param slot_addr Address of the pointer-sized slot to probe.
97 * @param out Receives the identification on success; unspecified on an error return.
98 * @return A value on resolve, or the typed Error on failure.
99 */
100 [[nodiscard]] Result<void> identify_pointee_typed(Address slot_addr, PointeeType &out) noexcept;
101
102 /**
103 * @concept SlotAddress
104 * @brief A value usable as a probe slot address: an @ref Address (or nullptr).
105 * @details Raw pointers and bare integers are rejected because Address's converting constructors are explicit.
106 * Wrap one in `Address{...}` at the call site.
107 */
108 template <typename T>
109 concept SlotAddress = std::convertible_to<T, Address>;
110
111 /**
112 * @brief Reverse-RTTI-identify the first of several candidate slots that resolves.
113 * @details Probes in declaration order and stops at the first resolve. If all miss, returns the primary
114 * error and resets @p out; declaration order is the only tie-breaker between valid candidates.
115 *
116 * @tparam Fallbacks Pack of alternate slot addresses, each an @ref Address.
117 * @param candidate The primary slot address to probe first.
118 * @param out Receives the first resolving slot's identification; reset to a default PointeeType on failure.
119 * @param fallbacks Alternate slot addresses, tried in order after @p candidate.
120 * @return A value on first resolve (@p out populated), or the @p candidate's Error when all candidates fail.
121 */
122 template <SlotAddress... Fallbacks>
123 [[nodiscard]] Result<void>
124 7 identify_pointee_type_or(Address candidate, PointeeType &out, Fallbacks... fallbacks) noexcept
125 {
126 // Capture the primary's typed error before the fold runs so a later probe cannot clobber the value we
127 // preserve; Error is a trivially copyable value.
128 7 Result<void> primary = identify_pointee_typed(candidate, out);
129
5/6
std::expected<void, DetourModKit::Error> DetourModKit::rtti::identify_pointee_type_or<>(DetourModKit::Address, DetourModKit::rtti::PointeeType&):
✓ Branch 4 → 5 taken 2 times.
✓ Branch 4 → 6 taken 1 time.
std::expected<void, DetourModKit::Error> DetourModKit::rtti::identify_pointee_type_or<DetourModKit::Address>(DetourModKit::Address, DetourModKit::rtti::PointeeType&, DetourModKit::Address):
✓ Branch 4 → 5 taken 1 time.
✓ Branch 4 → 6 taken 1 time.
std::expected<void, DetourModKit::Error> DetourModKit::rtti::identify_pointee_type_or<DetourModKit::Address, DetourModKit::Address>(DetourModKit::Address, DetourModKit::rtti::PointeeType&, DetourModKit::Address, DetourModKit::Address):
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 2 times.
7 if (primary)
130 {
131 3 return {};
132 }
133 // Unary left fold over ||: left-to-right, short-circuiting at the first resolver, so no fallback past the
134 // winner is probed.
135
3/4
✓ Branch 8 → 9 taken 1 time.
✓ Branch 8 → 12 taken 1 time.
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 13 taken 1 time.
4 const bool any = (identify_pointee_typed(static_cast<Address>(fallbacks), out).has_value() || ...);
136
3/4
std::expected<void, DetourModKit::Error> DetourModKit::rtti::identify_pointee_type_or<DetourModKit::Address>(DetourModKit::Address, DetourModKit::rtti::PointeeType&, DetourModKit::Address):
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 1 time.
std::expected<void, DetourModKit::Error> DetourModKit::rtti::identify_pointee_type_or<DetourModKit::Address, DetourModKit::Address>(DetourModKit::Address, DetourModKit::rtti::PointeeType&, DetourModKit::Address, DetourModKit::Address):
✓ Branch 14 → 15 taken 1 time.
✓ Branch 14 → 16 taken 1 time.
3 if (any)
137 {
138 1 return {};
139 }
140 // Every candidate failed. The last probe may have left @p out half-written, so reset it. The FIRST
141 // (primary) error is the one surfaced.
142 3 out = PointeeType{};
143 3 return primary;
144 }
145
146 /**
147 * @struct LabeledSlot
148 * @brief One slot from a @ref reverse_scan_block sweep that resolved to a real RTTI type.
149 */
150 struct LabeledSlot
151 {
152 /// Address of the resolved slot.
153 Address slot_addr{};
154 /// Zero-based index of the slot in the swept block.
155 std::size_t slot_index = 0;
156 /// Reverse-identified type (carries its own name buffer).
157 PointeeType type;
158 };
159
160 /**
161 * @brief RTTI-label a block of pointer-sized slots.
162 * @details Walks @p slot_count slots from @p start (stepping by @p stride) and appends a @ref LabeledSlot for
163 * every slot that @ref identify_pointee_type resolves.
164 * @param start Address of the first slot.
165 * @param slot_count Number of slots to probe.
166 * @param out Receives the resolved slots, appended in slot order.
167 * @param stride Byte distance between adjacent slots. Zero is treated as sizeof(std::uintptr_t).
168 * @return Number of slots appended to @p out.
169 * @warning ALLOCATES (grows @p out) and calls the syscall-heavy prelude per slot. Init-time / tooling only,
170 * never the hot path.
171 * @note The (slot_count * stride) span is overflow-guarded; a malformed tuple is treated as an empty block and
172 * returns 0. If a reallocation of @p out throws, the sweep stops early and returns the count appended so
173 * far (the noexcept contract holds).
174 */
175 [[nodiscard]] std::size_t reverse_scan_block(
176 Address start,
177 std::size_t slot_count,
178 std::vector<LabeledSlot> &out,
179 std::size_t stride = sizeof(std::uintptr_t)
180 ) noexcept;
181
182 /**
183 * @brief Byte-length overload of @ref reverse_scan_block.
184 * @details Equivalent to reverse_scan_block(start, byte_len / stride, out, stride).
185 * @param start Address of the first slot.
186 * @param byte_len Length of the block in bytes.
187 * @param out Receives the resolved slots, appended in slot order.
188 * @param stride Byte distance between adjacent slots. Zero is treated as sizeof(std::uintptr_t).
189 * @return Number of slots appended to @p out.
190 */
191 [[nodiscard]] std::size_t reverse_scan_block_bytes(
192 Address start,
193 std::size_t byte_len,
194 std::vector<LabeledSlot> &out,
195 std::size_t stride = sizeof(std::uintptr_t)
196 ) noexcept;
197
198 /**
199 * @enum Indirection
200 * @brief Slot shape (and, for @ref CompleteObject, subobject position) a self-heal landmark requires of a
201 * matching slot.
202 * @details Applied as a policy filter on top of @ref identify_pointee_type's resolvability classification.
203 * @note Under multiple inheritance every base subobject's COL names the same most-derived type. Only
204 * COL.offset distinguishes them, so an @ref ObjectBase or @ref Any heal can match a secondary base and
205 * report an offset shifted by that subobject delta. Use @ref CompleteObject for an object that may use
206 * multiple inheritance: it matches only COL.offset == 0.
207 */
208 enum class Indirection : std::uint8_t
209 {
210 /// Match only slots that held a pointer-to-object.
211 PointerToObject = 0,
212 /// Match only a direct object base (any subobject, including a multiple-inheritance secondary).
213 ObjectBase = 1,
214 /// Match either shape (use when capture and heal may straddle a DLL boundary).
215 Any = 2,
216 /**
217 * @brief Match only a direct object base whose vtable is the most-derived (primary) subobject,
218 * COL.offset == 0.
219 * @details Rejects a multiple-inheritance secondary base, so a heal cannot latch a secondary slot and
220 * report an offset shifted by the subobject delta. Prefer it when the landmarked object may have
221 * more than one base.
222 */
223 CompleteObject = 3
224 };
225
226 /**
227 * @struct Landmark
228 * @brief A consumer-owned, serializable record of "a field of a known type lives near a known offset within a
229 * struct."
230 * @details Every field except @ref base is persistable. @ref base is an ASLR'd runtime address, resolved
231 * fresh each session and filled in at call time.
232 * @note @ref expected_mangled must name a type that is stable across patches, because matching is byte-exact
233 * on the most-derived name. A rename defeats healing and fails closed via @ref ErrorCode::HealNoMatch.
234 * @note @ref expected_mangled is OWNED (a std::string), so a Landmark built from a transient string_view holds
235 * no dangling view.
236 */
237 struct Landmark
238 {
239 /// Resolved struct base. Filled at call time; never persisted.
240 Address base{};
241 /// Last known field offset within @ref base.
242 std::ptrdiff_t nominal_offset = 0;
243 /// Search radius per side in bytes (capped at MAX_HEAL_WINDOW).
244 std::size_t window = 0x40;
245 /// Owned MSVC mangled name to match (byte-exact on the most-derived name).
246 std::string expected_mangled;
247 /// Required slot shape.
248 Indirection indirection = Indirection::PointerToObject;
249 /// Probe step (and candidate alignment). Zero -> 8.
250 std::size_t stride = sizeof(std::uintptr_t);
251 /// Consulted only by @ref solve_fingerprint; a required landmark must match.
252 bool required = true;
253 };
254
255 /**
256 * @struct HealHit
257 * @brief Successful self-heal outcome from @ref heal_landmark.
258 */
259 struct HealHit
260 {
261 /// slot_addr - base: the field offset to use (== nominal_offset on no drift).
262 std::ptrdiff_t healed_offset = 0;
263 /// Address of the matching slot.
264 Address slot_addr{};
265 /// Resolved object base behind the slot.
266 Address object_addr{};
267 /// Resolved vtable of the matched object.
268 Address vtable{};
269 /**
270 * @brief COL.offset of the matched object: 0 for the primary (complete) subobject, nonzero for a
271 * multiple-inheritance secondary base.
272 * @details On a direct-object match, a nonzero value means the slot landed on a secondary base, so
273 * @ref healed_offset is shifted from the complete-object base. Always 0 under
274 * @ref Indirection::CompleteObject.
275 */
276 std::uint32_t col_offset = 0;
277 /// Shape of the matched slot.
278 bool was_pointer = false;
279 };
280
281 /**
282 * @brief Self-heal one field offset after a layout shift.
283 * @details Checks the nominal slot (@c base + @c nominal_offset) first. An unchanged offset short-circuits
284 * and never trips the ambiguity test. On a nominal miss it scans the +/- @c window grid
285 * nearest-first, stepping by @c stride, and returns the uniquely nearest slot that resolves via
286 * @ref identify_pointee_type, satisfies @c indirection, and byte-equals @c expected_mangled on the
287 * most-derived name.
288 * @param lm The landmark, with @c base filled in.
289 * @return The healed offset and match details, or:
290 * - @ref ErrorCode::BadDescriptor for a malformed landmark (low @c base, empty/oversized name, unknown
291 * @c indirection, @c window over MAX_HEAL_WINDOW, or a nominal address outside the user-mode
292 * window), before any read;
293 * - @ref ErrorCode::HealNoMatch when no slot matched;
294 * - @ref ErrorCode::HealAmbiguous when the @c +d and @c -d slots at the nearest matching distance both
295 * match.
296 * @warning FAIL-WRONG HAZARD in a crowded window: a strictly-nearer same-typed decoy slot, or a
297 * multiple-inheritance secondary base, wins SILENTLY and returns a confidently-wrong offset.
298 * @ref ErrorCode::HealAmbiguous fires only for an exact +/- distance tie, never for a nearer decoy.
299 * When the window may be crowded, prefer @ref solve_fingerprint (one uniform delta must fit every
300 * field at once), use @ref Indirection::CompleteObject, or narrow @c window.
301 * @note Init-time / re-heal-on-miss, not per-frame: each probe runs the syscall-heavy prelude up to twice. The
302 * window cap bounds the worst case. Allocates nothing (one reused stack @ref PointeeType).
303 */
304 [[nodiscard]] Result<HealHit> heal_landmark(const Landmark &lm) noexcept;
305
306 /**
307 * @struct FingerprintHit
308 * @brief Successful outcome from @ref solve_fingerprint.
309 */
310 struct FingerprintHit
311 {
312 /// The single uniform byte shift applied to every landmark offset.
313 std::ptrdiff_t delta = 0;
314 /// Required landmarks satisfied at @ref delta (equals the required count).
315 std::size_t matched = 0;
316 /// Optional landmarks also satisfied at @ref delta.
317 std::size_t optional_matched = 0;
318 };
319
320 /**
321 * @brief Rigid multi-field drift recovery.
322 * @details Finds the single uniform delta in [-window_bytes, +window_bytes] (stepping by
323 * sizeof(std::uintptr_t)) such that every required landmark at @c base + @c nominal_offset + @c delta
324 * reverse-resolves to its type with its required shape. Optional landmarks (@c required == false) are
325 * scored only to break ties between deltas that satisfy every required landmark.
326 * @param base Resolved struct base (the landmarks' own @c base fields are ignored; this one is used for every
327 * probe).
328 * @param fp The landmark template. Each landmark's @c nominal_offset, @c expected_mangled, @c indirection, and
329 * @c required are consulted; @c window and @c stride are not (probing is a single shifted slot, not a
330 * per-landmark window).
331 * @param window_bytes Maximum uniform shift to search per side, capped at MAX_HEAL_WINDOW.
332 * @return The recovered delta, or:
333 * - @ref ErrorCode::BadDescriptor for an empty span, over-cap span, no required landmark, an oversized
334 * @p window_bytes, a malformed landmark, or a low @p base;
335 * - @ref ErrorCode::HealNoMatch when no delta satisfied every required landmark;
336 * - @ref ErrorCode::HealAmbiguous when two or more nonzero deltas tie for the most optional matches. A
337 * zero-drift delta that satisfies every required landmark wins a top-score tie outright. A strictly
338 * higher optional score at any delta still wins.
339 * @note Each landmark in @p fp must have a distinct @c nominal_offset. Duplicate offsets probe the same slot
340 * and double-count it, so they are rejected as @ref ErrorCode::BadDescriptor before any memory is
341 * touched.
342 * @warning Init-time only: the probe count is (2 * window_bytes / 8 + 1) * fp.size() prelude walks. Allocates
343 * nothing.
344 */
345 [[nodiscard]] Result<FingerprintHit>
346 solve_fingerprint(Address base, std::span<const Landmark> fp, std::size_t window_bytes) noexcept;
347
348 /**
349 * @struct DriftEntry
350 * @brief One landmark's heal outcome, for a structured drift report.
351 * @details All fields are derived from an existing @ref heal_landmark result. This adds no new analysis.
352 */
353 struct DriftEntry
354 {
355 /// Aliases the landmark's @c expected_mangled.
356 std::string_view name;
357 /// The landmark's last-known offset.
358 std::ptrdiff_t nominal_offset = 0;
359 /// The resolved offset (valid only when @ref ok).
360 std::ptrdiff_t healed_offset = 0;
361 /// healed_offset - nominal_offset (valid only when @ref ok).
362 std::ptrdiff_t delta = 0;
363 /// Whether the landmark healed.
364 bool ok = false;
365 /// Failure code (its category is @ref ErrorCategory::Rtti); meaningful only when @ref ok is false.
366 ErrorCode error{ErrorCode::Ok};
367 };
368
369 /**
370 * @brief Heals a set of landmarks and writes a per-landmark drift report.
371 * @details Runs @ref heal_landmark on each landmark in order and records the outcome into @p out. Each
372 * landmark must already have its @c base filled in. Adds no reads over the individual heals and
373 * allocates nothing.
374 * @param landmarks The landmarks to heal (each with @c base set).
375 * @param out Destination, parallel to @p landmarks. At most @c out.size() entries are written.
376 * @return The number of entries written: @c min(landmarks.size(), out.size()).
377 */
378 [[nodiscard]] std::size_t heal_report(std::span<const Landmark> landmarks, std::span<DriftEntry> out) noexcept;
379
380 /**
381 * @enum OffsetValidity
382 * @brief Whether a healed-offset value may be consumed, and how
383 * strongly.
384 */
385 enum class OffsetValidity : std::uint8_t
386 {
387 /// A required heal missed: the retained value is unverified and has no established image generation.
388 Invalid = 0,
389 /** @brief An optional miss retained a nominal that is only usable as a hint. */
390 Unverified = 1,
391 /// A heal resolved the offset with a nonzero image generation.
392 Confirmed = 2
393 };
394
395 /**
396 * @struct HealedOffset
397 * @brief A consistent snapshot of a healed-offset slot: value, resolving-image generation, and validity.
398 * @details A confirmed value is stamped from the resolved vtable's image. Invalid and Unverified snapshots use
399 * generation 0 because no matching image established the layout.
400 */
401 struct HealedOffset
402 {
403 /** @brief The offset, meaningful for consumption only when validity is Confirmed. */
404 std::ptrdiff_t value = 0;
405 /// @ref rtti::image_generation of the resolved vtable's image; 0 until a heal confirms the value.
406 std::uint64_t generation = 0;
407 /// Whether @ref value may be consumed.
408 OffsetValidity validity = OffsetValidity::Invalid;
409
410 /// True only when the value is Confirmed and carries a nonzero image generation.
411 6 [[nodiscard]] bool usable() const noexcept
412 {
413
3/4
✓ Branch 2 → 3 taken 2 times.
✓ Branch 2 → 5 taken 4 times.
✓ Branch 3 → 4 taken 2 times.
✗ Branch 3 → 5 not taken.
6 return validity == OffsetValidity::Confirmed && generation != 0;
414 }
415 };
416
417 /**
418 * @class HealedSlot
419 * @brief Validity-bearing cross-thread channel for one healed offset: the safe alternative to a bare
420 * @c std::atomic<std::ptrdiff_t>.
421 * @details Publishing is single-producer (the heal thread). Loads use a bounded seqlock retry and return an
422 * Invalid snapshot if contention persists, so a consumer never blocks the producer or accepts a torn
423 * value. Hold one per offset at a stable address.
424 * @note The raw @c std::atomic<std::ptrdiff_t> @ref HealRun::heal_into overload carries no validity, so a
425 * required miss leaves a consumable nominal. Prefer this channel whenever a healed offset authorizes a
426 * write or a hook.
427 */
428 class HealedSlot
429 {
430 public:
431 HealedSlot() noexcept = default;
432 HealedSlot(const HealedSlot &) = delete;
433 HealedSlot &operator=(const HealedSlot &) = delete;
434 HealedSlot(HealedSlot &&) = delete;
435 HealedSlot &operator=(HealedSlot &&) = delete;
436 ~HealedSlot() noexcept = default;
437
438 /**
439 * @brief Seeds the slot with an unconfirmed nominal offset (generation 0, @ref OffsetValidity::Unverified).
440 * @details A consumer that reads the slot before the first successful heal gets the nominal with an
441 * explicit Unverified status, never a Confirmed value.
442 */
443 void seed_nominal(std::ptrdiff_t nominal) noexcept;
444
445 /**
446 * @brief Publishes a snapshot atomically (single producer).
447 * @details Non-Confirmed states are normalized to generation 0; Confirmed with generation 0 becomes
448 * Invalid.
449 */
450 void publish(std::ptrdiff_t value, std::uint64_t generation, OffsetValidity validity) noexcept;
451
452 /// Returns a consistent snapshot, or Invalid if bounded retries cannot observe one.
453 [[nodiscard]] HealedOffset load() const noexcept;
454
455 /**
456 * @brief Returns the offset only when it is @ref OffsetValidity::Confirmed.
457 * @return The Confirmed value, or @ref ErrorCode::OffsetNotConfirmed when validity or generation is absent.
458 * This is the validity gate; it does not check the current
459 * generation. Use the overload taking @p current_generation to also reject a stale image.
460 * @note Callback-safe: a bounded seqlock read, no allocation, locking, or I/O.
461 * @warning For mutation authorization tied to a module mapping, use the generation-checking overload.
462 */
463 [[nodiscard]] Result<std::ptrdiff_t> authorized() const noexcept;
464
465 /**
466 * @brief Returns the offset only when it is Confirmed AND still tied to @p current_generation.
467 * @param current_generation A nonzero, current @ref rtti::image_generation of the resolved type's module.
468 * @return The value, or @ref ErrorCode::OffsetNotConfirmed when the slot is not Confirmed or its generation
469 * is zero or no longer matches @p current_generation.
470 */
471 [[nodiscard]] Result<std::ptrdiff_t> authorized(std::uint64_t current_generation) const noexcept;
472
473 private:
474 // Single-producer seqlock: even = stable, odd = write in progress. The payload atomics are read/written
475 // relaxed and made consistent by the sequence counter's acquire/release fences.
476 std::atomic<std::uint32_t> m_seq{0};
477 std::atomic<std::ptrdiff_t> m_value{0};
478 std::atomic<std::uint64_t> m_generation{0};
479 std::atomic<std::uint8_t> m_validity{static_cast<std::uint8_t>(OffsetValidity::Invalid)};
480 };
481
482 /**
483 * @enum HealEscalation
484 * @brief Log-severity policy a @ref HealScheduler applies to a landmark that does not resolve during a scan.
485 */
486 enum class HealEscalation : std::uint8_t
487 {
488 /// A required landmark that stays unresolved logs at Warning. An optional miss stays at Debug. The default.
489 WarnRequired = 0,
490 /// Every miss (required or optional) stays at Debug.
491 Quiet = 1
492 };
493
494 /**
495 * @struct HealConfig
496 * @brief Tunables for a @ref HealScheduler: retry cadence, drift-warning threshold, and miss escalation.
497 */
498 struct HealConfig
499 {
500 /**
501 * @brief Frames between retry scans of an un-latched group. The interval is fixed, with no attempt cap: a
502 * group retries until it resolves, then latches and stops.
503 * @note A value of 0 is rejected by @ref HealScheduler::start with @ref ErrorCode::InvalidArg.
504 */
505 std::uint32_t interval_frames = 30;
506 /**
507 * @brief A realised drift whose absolute delta exceeds this threshold fires the one-shot layout-drift
508 * Warning. The default of 0 warns on ANY nonzero drift.
509 * @note A negative value is rejected by @ref HealScheduler::start with @ref ErrorCode::InvalidArg.
510 */
511 std::ptrdiff_t drift_warn_threshold = 0;
512 /// Log-severity policy for a landmark that does not resolve during a scan.
513 HealEscalation escalate = HealEscalation::WarnRequired;
514 };
515
516 class HealScheduler;
517
518 /**
519 * @class HealRun
520 * @brief The per-scan heal context a @ref HealScheduler hands to a group's work callback.
521 * @details A transient view over the scheduler's state, valid only for the duration of the callback. Do not
522 * store it.
523 */
524 class HealRun
525 {
526 public:
527 // Aliases the scheduler's config and warn-once state, so copy/move are deleted to keep the transient
528 // lifetime unextendable.
529 HealRun(const HealRun &) = delete;
530 HealRun &operator=(const HealRun &) = delete;
531 HealRun(HealRun &&) = delete;
532 HealRun &operator=(HealRun &&) = delete;
533
534 /**
535 * @brief Heal one landmark from a live base and publish the result to a caller-owned offset slot.
536 * @details Runs @ref heal_landmark at @p base, stores only a resolved offset, and logs confirmation,
537 * drift, or failure under @ref HealConfig::escalate. A miss leaves the raw slot untouched.
538 * @param label Short human-readable field name for the log lines.
539 * @param landmark The landmark template; its own @c base is ignored in favour of @p base.
540 * @param base The live, resolved struct base for this frame.
541 * @param slot The caller-owned offset cache slot (typically seeded with the nominal offset).
542 * @param required Whether an unresolved miss escalates to Warning under @ref HealEscalation::WarnRequired.
543 * @return The @ref heal_landmark result (the caller can inspect the details or the Error).
544 * @warning A raw atomic carries no validity, so a retained nominal cannot authorize mutation. Use the
545 * @ref HealedSlot overload for writes or hooks; this form remains for compatibility/read-only
546 * use.
547 */
548 [[nodiscard]] Result<HealHit> heal_into(
549 std::string_view label,
550 const Landmark &landmark,
551 Address base,
552 std::atomic<std::ptrdiff_t> &slot,
553 bool required = true
554 ) noexcept;
555
556 /**
557 * @brief Validity-bearing form of @ref heal_into: publishes {value, generation, validity} to a @ref
558 * HealedSlot instead of a bare atomic.
559 * @details A resolve publishes Confirmed with the nonzero image generation that brackets
560 * re-established evidence. Generation drift, missing identity, or changed evidence returns
561 * @ref ErrorCode::OffsetNotConfirmed. Any miss retains the value but publishes Invalid when
562 * required or Unverified when optional, so @ref HealedSlot::authorized rejects it.
563 * @param label Short human-readable field name for the log lines.
564 * @param landmark The landmark template; its own @c base is ignored in favour of @p base.
565 * @param base The live, resolved struct base for this frame.
566 * @param slot The caller-owned validity-bearing slot (typically @ref HealedSlot::seed_nominal'd first).
567 * @param required True marks a missing target as a required-field failure: it escalates the log to Warning
568 * under @ref HealEscalation::WarnRequired AND publishes @ref OffsetValidity::Invalid
569 * rather than @ref OffsetValidity::Unverified.
570 * @return The @ref heal_landmark result, or @ref ErrorCode::OffsetNotConfirmed when the heal resolved but
571 * its vtable image carried no stable generation across the evidence.
572 */
573 [[nodiscard]] Result<HealHit> heal_into(
574 std::string_view label,
575 const Landmark &landmark,
576 Address base,
577 HealedSlot &slot,
578 bool required = true
579 ) noexcept;
580
581 /**
582 * @brief Report a drift a group recovered itself (e.g. through @ref solve_fingerprint), so the one-shot
583 * Warning and the per-field Info line fire consistently with @ref heal_into.
584 * @details Use this for a corroborated bracket that writes its own slots: after storing the shifted
585 * offsets, call note_drift once per moved field. A zero delta logs a nominal confirmation at Debug
586 * and fires no Warning.
587 * @param label Short human-readable field name.
588 * @param nominal_offset The field's last-known offset.
589 * @param healed_offset The recovered offset.
590 */
591 void
592 note_drift(std::string_view label, std::ptrdiff_t nominal_offset, std::ptrdiff_t healed_offset) noexcept;
593
594 private:
595 friend class HealScheduler;
596 355 HealRun(const HealConfig &config, std::atomic<bool> &drift_warned) noexcept
597 355 : m_config(config), m_drift_warned(drift_warned)
598 {
599 355 }
600
601 // Fires the one-shot layout-drift Warning if |delta| exceeds the configured threshold and no earlier drift
602 // has already claimed the latch (CAS, so exactly one Warning is emitted across the whole scheduler).
603 void warn_drift_once(std::string_view label, std::ptrdiff_t delta) noexcept;
604
605 const HealConfig &m_config;
606 std::atomic<bool> &m_drift_warned;
607 };
608
609 /**
610 * @class HealScheduler
611 * @brief Frame-driven runner for a set of independently-latched self-heal groups.
612 * @details On each @ref tick, every un-latched group that waited out the frame interval runs its heal work. A
613 * group that reports success latches and stops. The first realised drift across a scheduler's groups
614 * fires that scheduler's one layout-drift Warning (a CAS one-shot). A group's gate runs before the
615 * interval countdown, so an unconstructed target is skipped without spending the retry budget or
616 * logging.
617 * @note Render-thread only, single-owner, move-only. The offset slots a group writes are the cross-thread
618 * channel, not the scheduler itself.
619 */
620 class HealScheduler
621 {
622 public:
623 /// A cheap per-frame precondition; returning false skips the group's scan without spending the interval.
624 using Gate = std::move_only_function<bool()>;
625 /**
626 * A group's heal work; returning true latches the group (no more scans). Returning false retries next
627 * interval.
628 */
629 using Work = std::move_only_function<bool(HealRun &)>;
630
631 /**
632 * @brief Constructs a scheduler with the given config.
633 * @param config Retry cadence, drift-warning threshold, and miss escalation.
634 * @return The scheduler, or @ref ErrorCode::InvalidArg for a zero interval or negative drift threshold.
635 */
636 [[nodiscard]] static Result<HealScheduler> start(HealConfig config = {}) noexcept;
637
638 HealScheduler(HealScheduler &&) noexcept;
639 HealScheduler &operator=(HealScheduler &&) noexcept;
640 HealScheduler(const HealScheduler &) = delete;
641 HealScheduler &operator=(const HealScheduler &) = delete;
642 ~HealScheduler() noexcept;
643
644 /**
645 * @brief Registers an independently-latched heal group.
646 * @param work The group's heal work, run on the configured interval while un-latched. Returning true
647 * latches the group; returning false retries on the next interval.
648 * @param gate Optional per-frame precondition, evaluated before the interval countdown. When it returns
649 * false the group is skipped silently and the interval budget is not spent, so a not-yet-live
650 * target is polled cheaply every frame until it appears.
651 * @return Empty on success. Returns @ref ErrorCode::OutOfMemory when the registration allocation fails.
652 * The scheduler is unchanged and no group is registered.
653 * @note An empty @p work is ignored (no group is registered, reported as success). A re-entrant call from
654 * within @ref tick defers the new group to the next tick. A registered group counts from this call,
655 * so @ref all_resolved reports false until it latches.
656 * @note Setup/control-plane only: registration can allocate and mutate scheduler state.
657 */
658 [[nodiscard]] Result<void> add_group(Work work, Gate gate = {}) noexcept;
659
660 /**
661 * @brief Advances the scheduler by one frame: scans every un-latched, gate-passing, interval-due group.
662 * @details Never throws. A work or gate callback that throws is treated as "did not resolve this frame".
663 * Deferred groups are adopted at tick exit. An adoption that failed on memory pressure is retried
664 * at the next tick's entry, so no tick count is lost.
665 */
666 void tick() noexcept;
667
668 /**
669 * @brief Returns true when every registered group has latched (vacuously true with no groups).
670 * @details Covers groups still waiting in the deferred-adoption queue.
671 */
672 [[nodiscard]] bool all_resolved() const noexcept;
673
674 /// Returns the config the scheduler was started with.
675 [[nodiscard]] const HealConfig &config() const noexcept;
676
677 private:
678 struct Impl;
679 explicit HealScheduler(std::unique_ptr<Impl> impl) noexcept;
680 std::unique_ptr<Impl> m_impl;
681 };
682 } // namespace rtti
683 } // namespace DetourModKit
684
685 #endif // DETOURMODKIT_RTTI_DISSECT_HPP
686