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/6std::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/4std::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 |