GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 92.3% 24 / 0 / 26
Functions: 100.0% 4 / 0 / 4
Branches: 78.6% 11 / 0 / 14

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 and self-healing offsets.
7 * @details The forward walker in rtti.hpp answers "what type is the object behind this vtable?". This header answers
8 * the inverse, slot-first questions a mod actually asks against a drifting game binary:
9 *
10 * - @ref identify_pointee_type -- "what object does this pointer
11 * slot refer to, and what is its RTTI type?" (the per-slot primitive).
12 * - @ref reverse_scan_block -- "RTTI-label every pointer slot in
13 * this struct" (allocating, init-time/tooling triage).
14 * - @ref heal_landmark / @ref heal_offset -- "a small patch shifted
15 * the layout; find where the field of type T moved to" (the self-healing offset resolver).
16 * - @ref solve_fingerprint -- "several fields co-moved; find the
17 * single uniform shift that satisfies every landmark" (rigid multi-field drift recovery).
18 *
19 * Every entry point is noexcept and fails closed. All reads go through the same SEH-guarded,
20 * module-bound-checked prelude the forward walker uses, so an unmapped page, a forged COL, or an ambiguous
21 * match is a clean failure return, never a fault and never a silently-wrong offset. Matching compares MSVC
22 * mangled bytes exactly; no UnDecorateSymbolName runs on any path. Scope is x64 MSVC.
23 */
24
25 #include "DetourModKit/rtti.hpp"
26
27 #include <concepts>
28 #include <cstddef>
29 #include <cstdint>
30 #include <expected>
31 #include <optional>
32 #include <span>
33 #include <string_view>
34 #include <vector>
35
36 namespace DetourModKit
37 {
38 namespace Rtti
39 {
40 /**
41 * @brief Hard cap on a self-heal search radius (bytes per side). Bounds the worst-case probe count so an
42 * accidental SIZE_MAX window cannot hang.
43 */
44 inline constexpr std::size_t MAX_HEAL_WINDOW = 4096;
45
46 /**
47 * @brief Hard cap on the number of landmarks in one @ref solve_fingerprint template.
48 */
49 inline constexpr std::size_t MAX_FINGERPRINT_LANDMARKS = 32;
50
51 /**
52 * @struct PointeeType
53 * @brief Result of reverse-identifying the object behind one slot.
54 * @details Populated by @ref identify_pointee_type on success. Carries the resolved vtable and COL coordinates,
55 * the object base, the complete-object base recovered from COL.offset, and an inline copy of the
56 * mangled name so the struct is self-contained (no pointer into transient buffers). The struct is ~1
57 * KiB because of @ref name_buf; the hot self-heal path reuses a single stack instance, while @ref
58 * reverse_scan_block embeds it by value (tooling only).
59 */
60 struct PointeeType
61 {
62 /// Resolved vtable pointer.
63 std::uintptr_t vtable = 0;
64 /// COL the vtable points back to.
65 std::uintptr_t col_addr = 0;
66 /// TypeDescriptor base.
67 std::uintptr_t td_addr = 0;
68 /// Mangled-name buffer (td_addr + 0x10).
69 std::uintptr_t name_addr = 0;
70 /// Start of the resolved (sub)object.
71 std::uintptr_t object_base = 0;
72 /// object_base - col_offset (underflow-clamped).
73 std::uintptr_t complete_obj = 0;
74 /// Raw qword read at the probed slot.
75 std::uintptr_t pointer_value = 0;
76 /// COL.offset (+0x04): this vtable's offset in the complete object.
77 std::uint32_t col_offset = 0;
78 /// true when the slot held a pointer-to-object (deref'd once).
79 bool was_pointer = false;
80 /// Length of the mangled name in @ref name_buf.
81 std::uint16_t name_len = 0;
82 /// NUL-terminated mangled name.
83 char name_buf[Rtti::MAX_TYPE_NAME_LEN + 1] = {};
84
85 /// Non-owning view of the mangled name held in @ref name_buf.
86 77 [[nodiscard]] std::string_view name() const noexcept { return std::string_view(name_buf, name_len); }
87 };
88
89 /**
90 * @enum IdentifyError
91 * @brief Reason a per-slot reverse-RTTI probe rejected a candidate slot. Every value fails closed.
92 * @details Ordered by the L1 control flow so the earliest failure is the most specific diagnostic a cascade can
93 * surface. The bool @ref identify_pointee_type collapses all of these to a false return; the typed
94 * @ref identify_pointee_typed and the @ref identify_pointee_type_or cascade preserve them.
95 */
96 enum class IdentifyError : std::uint8_t
97 {
98 /// The slot address itself was null or below the user-mode floor; no read was attempted.
99 BadSlotAddress,
100 /// The slot read faulted, or the qword held a null/low value.
101 UnreadableSlot,
102 /// The slot resolved to neither a pointer-to-object nor a direct object with a verifiable COL.
103 NoRtti
104 };
105
106 /**
107 * @brief Human-readable mapping for @ref IdentifyError.
108 * @param error The error code.
109 * @return A string view describing the error.
110 */
111 [[nodiscard]] std::string_view identify_error_to_string(IdentifyError error) noexcept;
112
113 /**
114 * @brief Reverse-RTTI-identify the object a pointer slot refers to.
115 * @details Reads the qword at @p slot_addr, then accepts whichever of two shapes resolves through the verified
116 * COL prelude:
117 * - pointer-to-object (tried first): the slot value is a
118 * pointer to an object; dereference once and resolve the pointee's vtable. @c was_pointer is set and
119 * @c object_base is the pointee.
120 * - direct object base: the slot itself is the object, its
121 * value is the vtable. @c was_pointer is clear and @c object_base is @p slot_addr.
122 *
123 * Classifying by resolvability rather than by module membership means an object whose vtable lives in
124 * a different
125 * DLL than the struct still resolves; @c was_pointer becomes a report, not a gate.
126 * @param slot_addr Address of the pointer-sized slot to probe.
127 * @param out Receives the identification on success. On a false return its contents are unspecified, so callers
128 * must check the return before reading it.
129 * @return true when a real RTTI type resolved, false on a null/low slot, an unreadable slot, or neither shape
130 * resolving.
131 */
132 [[nodiscard]] bool identify_pointee_type(std::uintptr_t slot_addr, PointeeType &out) noexcept;
133
134 /**
135 * @brief Typed form of @ref identify_pointee_type.
136 * @details Same probe and same @p out contract, but reports the specific fail-closed reason instead of a bool.
137 * @ref identify_pointee_type is exactly @c has_value() over this -- one probe, one prelude walk, one
138 * implementation. Use this (or @ref identify_pointee_type_or) when the reason for a miss matters
139 * (cascade diagnostics, telemetry); use the bool form otherwise.
140 * @param slot_addr Address of the pointer-sized slot to probe.
141 * @param out Receives the identification on success; unspecified on an error return.
142 * @return A value on resolve, or the typed reason on failure.
143 */
144 [[nodiscard]] std::expected<void, IdentifyError> identify_pointee_typed(std::uintptr_t slot_addr,
145 PointeeType &out) noexcept;
146
147 /**
148 * @concept SlotAddress
149 * @brief A value usable as a probe slot address: convertible to a raw pointer-sized integer.
150 * @details Constrains the @ref identify_pointee_type_or fallback pack so every alternate is a candidate
151 * ADDRESS,
152 * not a callable or an unrelated type. A raw pointer is intentionally rejected (it is not implicitly
153 * convertible to std::uintptr_t), steering consumers to pass an explicit reinterpret_cast as the rest
154 * of the API expects. A hard, readable compile error beats a deep template instantiation failure.
155 */
156 template <typename T>
157 concept SlotAddress = std::convertible_to<T, std::uintptr_t>;
158
159 /**
160 * @brief Reverse-RTTI-identify the first of several candidate slots that resolves.
161 * @details Probes @p candidate, then each fallback address in declaration order, stopping at the first slot
162 * that
163 * resolves; @p out then holds that slot's identification. When NO candidate resolves, returns the
164 * FIRST (the @p candidate's) failure -- the earliest, most specific fail-closed reason -- so a cascade
165 * reports "the primary slot was unreadable" rather than a generic miss. The fold short-circuits, so no
166 * fallback past the winner is probed and each candidate is probed at most once (no extra prelude walk
167 * per fallback beyond the single probe the bare primitive performs). The cascade selects the first
168 * RESOLVING slot, not the "best" one: with several valid-but-different objects, declaration order is
169 * the only disambiguator -- a consumer needing type discrimination uses @ref heal_landmark / @ref
170 * solve_fingerprint instead. On a failure return @p out is reset to a default-constructed @ref
171 * PointeeType, so a caller that ignores the error never reads a slot's partially written fields.
172 * @tparam Fallbacks Pack of alternate slot addresses, each convertible to std::uintptr_t.
173 * @param candidate The primary slot address to probe first.
174 * @param out Receives the first resolving slot's identification; reset to a default PointeeType on failure.
175 * @param fallbacks Alternate slot addresses, tried in order after @p candidate.
176 * @return A value on the first resolve (@p out populated), or the @p candidate's @ref IdentifyError when all
177 * candidates fail.
178 */
179 template <SlotAddress... Fallbacks>
180 [[nodiscard]] std::expected<void, IdentifyError>
181 7 identify_pointee_type_or(std::uintptr_t candidate, PointeeType &out, Fallbacks... fallbacks) noexcept
182 {
183 // Capture the primary's typed error before the fold runs so a later probe cannot clobber the value we
184 // preserve; std::expected::error() is a plain enum copy.
185 7 auto primary = identify_pointee_typed(candidate, out);
186
5/6
std::expected<void, DetourModKit::Rtti::IdentifyError> DetourModKit::Rtti::identify_pointee_type_or<>(unsigned long long, DetourModKit::Rtti::PointeeType&):
✓ Branch 4 → 5 taken 2 times.
✓ Branch 4 → 8 taken 1 time.
std::expected<void, DetourModKit::Rtti::IdentifyError> DetourModKit::Rtti::identify_pointee_type_or<unsigned long long>(unsigned long long, DetourModKit::Rtti::PointeeType&, unsigned long long):
✓ Branch 4 → 5 taken 1 time.
✓ Branch 4 → 8 taken 1 time.
std::expected<void, DetourModKit::Rtti::IdentifyError> DetourModKit::Rtti::identify_pointee_type_or<unsigned long long, unsigned long long>(unsigned long long, DetourModKit::Rtti::PointeeType&, unsigned long long, unsigned long long):
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 8 taken 2 times.
7 if (primary)
187 {
188 3 return {};
189 }
190 // Unary left fold over ||: left-to-right, short-circuiting at the first resolver, so no fallback past the
191 // winner is probed.
192
3/4
✓ Branch 10 → 11 taken 1 time.
✓ Branch 10 → 14 taken 1 time.
✗ Branch 13 → 14 not taken.
✓ Branch 13 → 15 taken 1 time.
4 const bool any = (identify_pointee_typed(static_cast<std::uintptr_t>(fallbacks), out).has_value() || ...);
193
3/4
std::expected<void, DetourModKit::Rtti::IdentifyError> DetourModKit::Rtti::identify_pointee_type_or<unsigned long long>(unsigned long long, DetourModKit::Rtti::PointeeType&, unsigned long long):
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 14 taken 1 time.
std::expected<void, DetourModKit::Rtti::IdentifyError> DetourModKit::Rtti::identify_pointee_type_or<unsigned long long, unsigned long long>(unsigned long long, DetourModKit::Rtti::PointeeType&, unsigned long long, unsigned long long):
✓ Branch 16 → 17 taken 1 time.
✓ Branch 16 → 20 taken 1 time.
3 if (any)
194 {
195 1 return {};
196 }
197 // Every candidate failed. The last probe may have left @p out half-written (e.g. a partial name_buf on a
198 // NoRtti-after-name miss), so reset it to a clean default before returning. This keeps a caller that
199 // ignores the error code from reading partially-written fields, while the FIRST (primary) error is still
200 // the one surfaced.
201 3 out = PointeeType{};
202 3 return std::unexpected(primary.error());
203 }
204
205 /**
206 * @struct LabeledSlot
207 * @brief One slot from a @ref reverse_scan_block sweep that resolved to a real RTTI type.
208 */
209 struct LabeledSlot
210 {
211 /// Address of the resolved slot.
212 std::uintptr_t slot_addr = 0;
213 /// Zero-based index of the slot in the swept block.
214 std::size_t slot_index = 0;
215 /// Reverse-identified type (carries its own name buffer).
216 PointeeType type;
217 };
218
219 /**
220 * @brief RTTI-label a block of pointer-sized slots.
221 * @details Walks @p slot_count slots from @p start (stepping by @p stride) and appends a @ref LabeledSlot for
222 * every slot that @ref identify_pointee_type resolves. This is the dump/triage face of the feature
223 * ("tell me the RTTI type of every pointer field in this struct").
224 * @param start Address of the first slot.
225 * @param slot_count Number of slots to probe.
226 * @param out Receives the resolved slots, appended in slot order.
227 * @param stride Byte distance between adjacent slots. Zero is treated as sizeof(std::uintptr_t).
228 * @return Number of slots appended to @p out.
229 * @warning ALLOCATES (grows @p out) and calls the syscall-heavy prelude per slot. Init-time / tooling only --
230 * never the hot path.
231 * @note The (slot_count * stride) span is overflow-guarded; a malformed tuple is treated as an empty block and
232 * returns 0. If a reallocation of @p out throws, the sweep stops early and returns the count appended so
233 * far (the noexcept contract holds).
234 */
235 [[nodiscard]] std::size_t reverse_scan_block(std::uintptr_t start, std::size_t slot_count,
236 std::vector<LabeledSlot> &out,
237 std::size_t stride = sizeof(std::uintptr_t)) noexcept;
238
239 /**
240 * @brief Byte-length overload of @ref reverse_scan_block.
241 * @details Equivalent to reverse_scan_block(start, byte_len / stride, out, stride).
242 * @param start Address of the first slot.
243 * @param byte_len Length of the block in bytes.
244 * @param out Receives the resolved slots, appended in slot order.
245 * @param stride Byte distance between adjacent slots. Zero is treated as sizeof(std::uintptr_t).
246 * @return Number of slots appended to @p out.
247 */
248 [[nodiscard]] std::size_t reverse_scan_block_bytes(std::uintptr_t start, std::size_t byte_len,
249 std::vector<LabeledSlot> &out,
250 std::size_t stride = sizeof(std::uintptr_t)) noexcept;
251
252 /**
253 * @enum Indirection
254 * @brief Slot shape (and, for @ref CompleteObject, subobject position) a self-heal landmark requires of a
255 * matching slot.
256 * @details Applied as a soft policy filter on top of @ref identify_pointee_type's resolvability classification.
257 * @note Under multiple inheritance each base subobject carries its own vtable, and each vtable's COL names the
258 * same most-derived type; COL.offset is what distinguishes them. A direct-object heal keyed on @ref
259 * ObjectBase or @ref Any can therefore match a secondary base's vtable and report an offset shifted by
260 * that subobject delta. Use @ref CompleteObject for an embedded object that may use multiple inheritance:
261 * it matches only the primary subobject (COL.offset == 0), so the healed offset is always the true
262 * complete-object base. @ref HealHit::was_pointer and @ref HealHit::col_offset let an @ref ObjectBase /
263 * @ref Any consumer detect the direct-object case after the fact.
264 */
265 enum class Indirection : std::uint8_t
266 {
267 /// Match only slots that held a pointer-to-object.
268 PointerToObject = 0,
269 /// Match only a direct object base (any subobject, including a multiple-inheritance secondary).
270 ObjectBase = 1,
271 /// Match either shape (use when capture and heal may straddle a DLL boundary).
272 Any = 2,
273 /**
274 * @brief Match only a direct object base whose vtable is the most-derived (primary) subobject,
275 * COL.offset == 0.
276 * @details Like @ref ObjectBase, but rejects a multiple-inheritance secondary base whose vtable sits
277 * adjacent to the primary and whose COL still names the complete type. This keeps a heal from
278 * latching that secondary slot and reporting an offset shifted by the subobject delta (a silent
279 * off-by-a-subobject heal). Prefer it whenever the landmarked object may have more than one base.
280 */
281 CompleteObject = 3
282 };
283
284 /**
285 * @enum HealError
286 * @brief Reasons a self-heal resolve may fail. Every value fails closed.
287 */
288 enum class HealError : std::uint8_t
289 {
290 /// The landmark/fingerprint is malformed; no memory was touched.
291 BadDescriptor,
292 /// No slot in the window resolved to the expected type.
293 NoMatch,
294 /// Equidistant slots both match (heal) or tied-score deltas (fingerprint).
295 Ambiguous
296 };
297
298 /**
299 * @struct Landmark
300 * @brief A consumer-owned, serializable record of "a field of a known type lives near a known offset within a
301 * struct."
302 * @details Recorded once and persisted (config). The persisted form carries @ref nominal_offset, @ref window,
303 * an owned copy of @ref expected_mangled, @ref indirection, @ref stride, and (for fingerprints) @ref
304 * required. @ref base is never persisted: it is an ASLR'd runtime address resolved fresh each session
305 * (typically from a Scanner cascade/AOB anchor or a live object pointer) and filled in at call time.
306 * The in-code common case is a @c static @c constexpr Landmark with a mangled string literal,
307 * mirroring the existing AddrCandidate cascade tables.
308 * @note @ref expected_mangled must name a type that is stable across patches (a base/engine type, not a
309 * game-specific most-derived subtype), because matching is byte-exact on the most-derived name. A subtype
310 * rename defeats healing and fails closed via @ref HealError::NoMatch.
311 */
312 struct Landmark
313 {
314 /// Resolved struct base. Filled at call time; never persisted.
315 std::uintptr_t base = 0;
316 /// Last known field offset within @ref base.
317 std::ptrdiff_t nominal_offset = 0;
318 /// Search radius per side in bytes (capped at MAX_HEAL_WINDOW).
319 std::size_t window = 0x40;
320 /**
321 * @brief MSVC mangled name to match. Aliases caller storage.
322 * @note Non-owning view: the backing bytes must outlive every heal call that reads this landmark. A string
323 * literal (the documented common case) and any longer-lived std::string / std::string_view are safe.
324 * Initialising this field from a std::string temporary dangles the view as soon as the full
325 * expression ends -- a latent use-after-free. Because Landmark is an aggregate (so a heal template
326 * can be a @c static @c constexpr designated initializer and stay serializable), this cannot be
327 * rejected with a deleted rvalue overload the way @ref Rtti::TypeIdentity does; the consumer owns the
328 * lifetime.
329 */
330 std::string_view expected_mangled;
331 /// Required slot shape.
332 Indirection indirection = Indirection::PointerToObject;
333 /// Probe step (and candidate alignment). Zero -> 8.
334 std::size_t stride = sizeof(std::uintptr_t);
335 /// Consulted only by @ref solve_fingerprint; a required landmark must match.
336 bool required = true;
337 };
338
339 /**
340 * @struct HealHit
341 * @brief Successful self-heal outcome from @ref heal_landmark.
342 */
343 struct HealHit
344 {
345 /// slot_addr - base: the field offset to use (== nominal_offset on no drift).
346 std::ptrdiff_t healed_offset = 0;
347 /// Address of the matching slot.
348 std::uintptr_t slot_addr = 0;
349 /// Resolved object base behind the slot.
350 std::uintptr_t object_addr = 0;
351 /// Resolved vtable of the matched object.
352 std::uintptr_t vtable = 0;
353 /**
354 * @brief COL.offset of the matched object: 0 for the primary (complete) subobject, nonzero for a
355 * multiple-inheritance secondary base.
356 * @details On a direct-object match (@ref was_pointer is false), a nonzero value means the slot landed on
357 * a secondary base, so @ref healed_offset is shifted that many bytes from the complete-object
358 * base. An @ref Indirection::CompleteObject heal only matches col_offset == 0, so this is always
359 * 0 there.
360 */
361 std::uint32_t col_offset = 0;
362 /// Shape of the matched slot.
363 bool was_pointer = false;
364 };
365
366 /**
367 * @brief Self-heal one field offset after a layout shift.
368 * @details Checks the nominal slot (@c base + @c nominal_offset) first;
369 * an unchanged offset short-circuits and returns immediately, so an unpatched binary -- or one with a
370 * same-typed neighbour in the window -- never trips the ambiguity test. On a nominal miss it scans the
371 * +/- @c window grid (stepping by @c stride, so probes stay congruent to the nominal slot and never
372 * straddle a field), nearest-first, and returns the uniquely nearest matching slot. A slot matches
373 * when it resolves via @ref identify_pointee_type, satisfies @c indirection, and its most-derived
374 * mangled name byte-equals @c expected_mangled.
375 * @param lm The landmark, with @c base filled in.
376 * @return The healed offset and match details, or:
377 * - @ref HealError::BadDescriptor for a malformed landmark
378 * (low @c base, empty/oversized name, unknown @c indirection, @c window over MAX_HEAL_WINDOW, or a
379 * nominal address outside
380 * the user-mode window), before any read;
381 * - @ref HealError::NoMatch when no slot matched;
382 * - @ref HealError::Ambiguous when both the @c +d and @c -d slots
383 * at the nearest matching distance match (an irreducible tie).
384 * @warning FAIL-WRONG HAZARD when the window is crowded with same-typed slots. A single landmark resolves to
385 * the
386 * uniquely NEAREST slot that satisfies the type + shape, so any of these wins SILENTLY and returns a
387 * confidently-wrong offset rather than failing closed:
388 * - a strictly-nearer same-typed DECOY field at the wrong offset (both satisfy the slot shape, and the
389 * nearer one is returned before the intended field is ever probed);
390 * - under multiple inheritance, a secondary base subobject whose vtable sits nearer than the primary
391 * and whose COL still names the complete type (use @ref Indirection::CompleteObject to reject it).
392 * The @ref HealError::Ambiguous result fires ONLY for an exact +/- distance tie at the nearest
393 * matching ring, never for a nearer decoy, so a wrong-but-nearer slot is not flagged. Whenever the
394 * window may be crowded -- a struct that holds more than one field of @c expected_mangled's type, or
395 * an object that may use multiple inheritance -- prefer @ref solve_fingerprint, which disambiguates
396 * structurally because one uniform delta must fit every field at once, or narrow @c window / tighten
397 * the type to a name that is unique in the neighbourhood.
398 * @note Init-time / re-heal-on-miss, not per-frame: each probe runs the syscall-heavy prelude up to twice. The
399 * window cap bounds the worst case. Allocates nothing (one reused stack @ref PointeeType).
400 */
401 [[nodiscard]] std::expected<HealHit, HealError> heal_landmark(const Landmark &lm) noexcept;
402
403 /**
404 * @brief Convenience wrapper over @ref heal_landmark returning just the healed byte offset.
405 * @details Feeds straight into a @c std::span<const std::ptrdiff_t> pointer-chain API.
406 * @param lm The landmark, with @c base filled in.
407 * @return The healed offset, or std::nullopt on any failure.
408 * @warning Inherits @ref heal_landmark's crowding FAIL-WRONG HAZARD, and discards every signal that would let a
409 * caller detect it: the std::nullopt return collapses all @ref HealError reasons, and the dropped @ref
410 * HealHit::col_offset / @ref HealHit::was_pointer hide a secondary-base or direct-object match. A
411 * strictly-nearer same-typed decoy (or an MI secondary base) still wins SILENTLY and yields a
412 * confidently-wrong offset with no indication. Use this only when the window cannot be crowded; when
413 * it can, call @ref heal_landmark (to inspect the full @ref HealHit) or @ref solve_fingerprint (to
414 * disambiguate structurally across several co-moving fields).
415 */
416 [[nodiscard]] std::optional<std::ptrdiff_t> heal_offset(const Landmark &lm) noexcept;
417
418 /**
419 * @brief Human-readable mapping for @ref HealError.
420 * @param error The error code.
421 * @return A string view describing the error.
422 */
423 [[nodiscard]] std::string_view heal_error_to_string(HealError error) noexcept;
424
425 /**
426 * @struct FingerprintHit
427 * @brief Successful outcome from @ref solve_fingerprint.
428 */
429 struct FingerprintHit
430 {
431 /// The single uniform byte shift applied to every landmark offset.
432 std::ptrdiff_t delta = 0;
433 /// Required landmarks satisfied at @ref delta (equals the required count).
434 std::size_t matched = 0;
435 /// Optional landmarks also satisfied at @ref delta.
436 std::size_t optional_matched = 0;
437 };
438
439 /**
440 * @brief Rigid multi-field drift recovery.
441 * @details Finds the single uniform delta in [-window_bytes, +window_bytes] (stepping by
442 * sizeof(std::uintptr_t)) such that every required landmark at @c base + @c nominal_offset + @c delta
443 * reverse-resolves to its type with its required shape. A dense window of same-typed neighbours that
444 * defeats a single @ref heal_landmark is structurally disambiguated here because one delta must fit
445 * the whole template at once. Optional landmarks (@c required == false) are scored only to break ties
446 * between deltas that satisfy every required landmark. Degenerates to a single-field solve when @p
447 * fp.size() == 1.
448 * @param base Resolved struct base (the landmarks' own @c base fields are ignored; this one is used for every
449 * probe).
450 * @param fp The landmark template. Each landmark's @c nominal_offset, @c expected_mangled, @c indirection, and
451 * @c required are consulted; @c window and @c stride are not (probing is a single shifted slot, not a
452 * per-landmark window).
453 * @param window_bytes Maximum uniform shift to search per side, capped at MAX_HEAL_WINDOW.
454 * @return The recovered delta, or:
455 * - @ref HealError::BadDescriptor for an empty span, over-cap
456 * span, no required landmark, an oversized @p window_bytes, a
457 * malformed landmark, or a low @p base;
458 * - @ref HealError::NoMatch when no delta satisfied every
459 * required landmark;
460 * - @ref HealError::Ambiguous when two or more deltas tie for the
461 * most optional matches.
462 * @note Each landmark in @p fp must have a distinct @c nominal_offset. Corroboration is scored by counting the
463 * required landmarks satisfied at a delta, so two landmarks sharing a nominal_offset would probe the same
464 * slot and double-count it. Duplicate offsets are rejected as @ref HealError::BadDescriptor before any
465 * memory is touched.
466 * @warning Init-time only: the probe count is (2 * window_bytes / 8 + 1) * fp.size() prelude walks. Allocates
467 * nothing.
468 */
469 [[nodiscard]] std::expected<FingerprintHit, HealError>
470 solve_fingerprint(std::uintptr_t base, std::span<const Landmark> fp, std::size_t window_bytes) noexcept;
471
472 /**
473 * @struct DriftEntry
474 * @brief One landmark's heal outcome, for a structured drift report.
475 * @details The raw "what moved and by how much" record a consumer logs to a changelog or scans to spot a
476 * patch's re-layout at a glance. All fields are derived from an existing @ref heal_landmark result;
477 * this adds no new analysis.
478 */
479 struct DriftEntry
480 {
481 /// Aliases the landmark's @c expected_mangled.
482 std::string_view name;
483 /// The landmark's last-known offset.
484 std::ptrdiff_t nominal_offset = 0;
485 /// The resolved offset (valid only when @ref ok).
486 std::ptrdiff_t healed_offset = 0;
487 /// healed_offset - nominal_offset (valid only when @ref ok).
488 std::ptrdiff_t delta = 0;
489 /// Whether the landmark healed.
490 bool ok = false;
491 /// Failure reason; meaningful only when @ref ok is false.
492 HealError error{};
493 };
494
495 /**
496 * @brief Heals a set of landmarks and writes a per-landmark drift report.
497 * @details Runs @ref heal_landmark on each landmark in order and records the outcome (nominal, healed, delta,
498 * or the typed failure) into @p out. Each landmark must already have its @c base filled in, exactly as
499 * for a direct @ref heal_landmark call. This is a thin aggregation over the existing heal path: it
500 * performs no read the individual heals would not, and allocates nothing.
501 * @param landmarks The landmarks to heal (each with @c base set).
502 * @param out Destination, parallel to @p landmarks. At most @c out.size() entries are written.
503 * @return The number of entries written: @c min(landmarks.size(), out.size()).
504 */
505 [[nodiscard]] std::size_t heal_report(std::span<const Landmark> landmarks, std::span<DriftEntry> out) noexcept;
506 } // namespace Rtti
507 } // namespace DetourModKit
508
509 #endif // DETOURMODKIT_RTTI_DISSECT_HPP
510