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 reaches foreign memory only through guarded reads and fails closed, except | ||
| 8 | * for the `[B-20]` re-arm fault that memory.hpp states. See docs/guides/rtti/rtti-self-heal.md. | ||
| 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 | /** @brief Hard cap on a self-heal search radius in bytes per side. */ | ||
| 32 | inline constexpr std::size_t MAX_HEAL_WINDOW = 4096; | ||
| 33 | |||
| 34 | /** @brief Hard cap on the number of landmarks in one @ref solve_fingerprint template. */ | ||
| 35 | inline constexpr std::size_t MAX_FINGERPRINT_LANDMARKS = 32; | ||
| 36 | |||
| 37 | /** @brief Result of @ref identify_pointee_type for one slot. It is about 1 KiB. */ | ||
| 38 | struct PointeeType | ||
| 39 | { | ||
| 40 | /// Resolved vtable pointer. | ||
| 41 | Address vtable{}; | ||
| 42 | /// COL the vtable points back to. | ||
| 43 | Address col_addr{}; | ||
| 44 | /// TypeDescriptor base. | ||
| 45 | Address td_addr{}; | ||
| 46 | /// Mangled-name buffer (td_addr + 0x10). | ||
| 47 | Address name_addr{}; | ||
| 48 | /// Start of the resolved (sub)object. | ||
| 49 | Address object_base{}; | ||
| 50 | /// object_base - col_offset (underflow-clamped). | ||
| 51 | Address complete_obj{}; | ||
| 52 | /// Raw qword read at the probed slot. | ||
| 53 | Address pointer_value{}; | ||
| 54 | /// COL.offset (+0x04): this vtable's offset in the complete object. | ||
| 55 | std::uint32_t col_offset = 0; | ||
| 56 | /// true when the slot held a pointer to the object, dereferenced once. | ||
| 57 | bool was_pointer = false; | ||
| 58 | /// Length of the mangled name in @ref name_buf. | ||
| 59 | std::uint16_t name_len = 0; | ||
| 60 | /// Inline NUL-terminated copy of the mangled name, so no field points into a transient buffer. | ||
| 61 | char name_buf[MAX_TYPE_NAME_LEN + 1] = {}; | ||
| 62 | |||
| 63 | /// Non-owning view of the mangled name held in @ref name_buf. | ||
| 64 | 91 | [[nodiscard]] std::string_view name() const noexcept { return std::string_view(name_buf, name_len); } | |
| 65 | }; | ||
| 66 | |||
| 67 | /** | ||
| 68 | * @brief Reverse-RTTI-identify the object a pointer slot refers to. | ||
| 69 | * @details Tries a pointer-to-object first, then a direct object base. @c was_pointer reports the shape that | ||
| 70 | * resolved and does not impose module locality. | ||
| 71 | * @param out Receives the identification. Its contents are unspecified after a false return. | ||
| 72 | * @return false on a null or low slot, an unreadable slot, or when neither shape resolves. | ||
| 73 | */ | ||
| 74 | [[nodiscard]] bool identify_pointee_type(Address slot_addr, PointeeType &out) noexcept; | ||
| 75 | |||
| 76 | /** | ||
| 77 | * @brief Typed form of @ref identify_pointee_type, which equals @c has_value() of this call. | ||
| 78 | * @return A value on resolve. @ref ErrorCode::BadSlotAddress reports a null or low slot. | ||
| 79 | * @ref ErrorCode::UnreadableSlot reports a faulted, null, or low slot value. @ref ErrorCode::NoRtti | ||
| 80 | * reports that neither shape carried a verifiable COL, or that its name is empty, unreadable, or has | ||
| 81 | * no terminator inside its module. @p out is unspecified after an error. | ||
| 82 | */ | ||
| 83 | [[nodiscard]] Result<void> identify_pointee_typed(Address slot_addr, PointeeType &out) noexcept; | ||
| 84 | |||
| 85 | /** @brief A probe slot address: an @ref Address or nullptr. Wrap a raw pointer or integer in `Address{...}`. */ | ||
| 86 | template <typename T> | ||
| 87 | concept SlotAddress = std::convertible_to<T, Address>; | ||
| 88 | |||
| 89 | /** | ||
| 90 | * @brief Reverse-RTTI-identify the first of several candidate slots that resolves. | ||
| 91 | * @details Probes @p candidate, then @p fallbacks in declaration order, and stops at the first resolve. | ||
| 92 | * @param out Receives the identification of the first slot that resolves. It is reset to a default PointeeType | ||
| 93 | * when every candidate fails. | ||
| 94 | * @return A value on the first resolve, or the error of @p candidate when every candidate fails. | ||
| 95 | */ | ||
| 96 | template <SlotAddress... Fallbacks> | ||
| 97 | [[nodiscard]] Result<void> | ||
| 98 | 7 | identify_pointee_type_or(Address candidate, PointeeType &out, Fallbacks... fallbacks) noexcept | |
| 99 | { | ||
| 100 | 7 | Result<void> primary = identify_pointee_typed(candidate, out); | |
| 101 | 7 | if (primary) | |
| 102 | { | ||
| 103 | 3 | return {}; | |
| 104 | } | ||
| 105 | 4 | const bool any = (identify_pointee_typed(static_cast<Address>(fallbacks), out).has_value() || ...); | |
| 106 | 3 | if (any) | |
| 107 | { | ||
| 108 | 1 | return {}; | |
| 109 | } | ||
| 110 | // The last probe can leave out half-written. | ||
| 111 | 3 | out = PointeeType{}; | |
| 112 | 3 | return primary; | |
| 113 | } | ||
| 114 | |||
| 115 | /** @brief One slot from a @ref reverse_scan_block sweep that resolved to a real RTTI type. */ | ||
| 116 | struct LabeledSlot | ||
| 117 | { | ||
| 118 | /// Address of the resolved slot. | ||
| 119 | Address slot_addr{}; | ||
| 120 | /// Zero-based index of the slot in the swept block. | ||
| 121 | std::size_t slot_index = 0; | ||
| 122 | /// Reverse-identified type (carries its own name buffer). | ||
| 123 | PointeeType type; | ||
| 124 | }; | ||
| 125 | |||
| 126 | /** | ||
| 127 | * @brief RTTI-label a block of pointer-sized slots. | ||
| 128 | * @details Walks @p slot_count slots from @p start, @p stride bytes apart, and appends a @ref LabeledSlot in | ||
| 129 | * slot order for every slot that @ref identify_pointee_type resolves. A zero @p stride means | ||
| 130 | * sizeof(std::uintptr_t). A (slot_count * stride) span that overflows counts as an empty block. | ||
| 131 | * @return Number of slots appended to @p out. If a reallocation of @p out throws, the sweep stops and returns | ||
| 132 | * the count appended so far. | ||
| 133 | * @warning Allocates (grows @p out) and runs the syscall-heavy prelude per slot. Use it at init time or in | ||
| 134 | * tooling, never on the hot path. | ||
| 135 | */ | ||
| 136 | [[nodiscard]] std::size_t reverse_scan_block( | ||
| 137 | Address start, | ||
| 138 | std::size_t slot_count, | ||
| 139 | std::vector<LabeledSlot> &out, | ||
| 140 | std::size_t stride = sizeof(std::uintptr_t) | ||
| 141 | ) noexcept; | ||
| 142 | |||
| 143 | /** | ||
| 144 | * @brief Byte-length form of @ref reverse_scan_block. | ||
| 145 | * @details Equals reverse_scan_block(start, byte_len / stride, out, stride), with the same zero-stride rule. | ||
| 146 | */ | ||
| 147 | [[nodiscard]] std::size_t reverse_scan_block_bytes( | ||
| 148 | Address start, | ||
| 149 | std::size_t byte_len, | ||
| 150 | std::vector<LabeledSlot> &out, | ||
| 151 | std::size_t stride = sizeof(std::uintptr_t) | ||
| 152 | ) noexcept; | ||
| 153 | |||
| 154 | /** | ||
| 155 | * @brief Slot shape, and for @ref CompleteObject the subobject position, that a self-heal landmark requires. | ||
| 156 | * @details Under multiple inheritance, every base subobject's COL names the same most-derived type. An | ||
| 157 | * @ref ObjectBase or @ref Any heal can therefore match a secondary base and report a shifted offset. | ||
| 158 | */ | ||
| 159 | enum class Indirection : std::uint8_t | ||
| 160 | { | ||
| 161 | /// Match only slots that held a pointer-to-object. | ||
| 162 | PointerToObject = 0, | ||
| 163 | /// Match only a direct object base at any subobject, a multiple-inheritance secondary base too. | ||
| 164 | ObjectBase = 1, | ||
| 165 | /// Match either shape. If capture and heal can straddle a DLL boundary, use it. | ||
| 166 | Any = 2, | ||
| 167 | /** @brief Match only a direct object base with COL.offset == 0. Prefer it under multiple inheritance. */ | ||
| 168 | CompleteObject = 3 | ||
| 169 | }; | ||
| 170 | |||
| 171 | /** | ||
| 172 | * @brief Consumer-owned, serializable record of a field of a known type near a known offset in a struct. | ||
| 173 | * @details @ref expected_mangled must name a type that stays stable across patches. A rename fails closed with | ||
| 174 | * @ref ErrorCode::HealNoMatch. | ||
| 175 | */ | ||
| 176 | struct Landmark | ||
| 177 | { | ||
| 178 | /// Resolved ASLR runtime struct base, filled at call time and never persisted. | ||
| 179 | Address base{}; | ||
| 180 | /// Last known field offset within @ref base. | ||
| 181 | std::ptrdiff_t nominal_offset = 0; | ||
| 182 | /// Search radius per side in bytes, at most MAX_HEAL_WINDOW. | ||
| 183 | std::size_t window = 0x40; | ||
| 184 | /// MSVC mangled name to match, byte-exact on the most-derived name. | ||
| 185 | std::string expected_mangled; | ||
| 186 | /// Required slot shape. | ||
| 187 | Indirection indirection = Indirection::PointerToObject; | ||
| 188 | /// Probe step and candidate alignment. Zero means 8. | ||
| 189 | std::size_t stride = sizeof(std::uintptr_t); | ||
| 190 | /// Consulted only by @ref solve_fingerprint. A required landmark must match. | ||
| 191 | bool required = true; | ||
| 192 | }; | ||
| 193 | |||
| 194 | /** @brief Successful self-heal outcome from @ref heal_landmark. */ | ||
| 195 | struct HealHit | ||
| 196 | { | ||
| 197 | /// slot_addr - base: the field offset to use (== nominal_offset on no drift). | ||
| 198 | std::ptrdiff_t healed_offset = 0; | ||
| 199 | /// Address of the slot that matched. | ||
| 200 | Address slot_addr{}; | ||
| 201 | /// Resolved object base behind the slot. | ||
| 202 | Address object_addr{}; | ||
| 203 | /// Resolved vtable of the matched object. | ||
| 204 | Address vtable{}; | ||
| 205 | /// Matched COL.offset. For a direct object, its complete object is at @ref healed_offset minus this value. | ||
| 206 | std::uint32_t col_offset = 0; | ||
| 207 | /// Shape of the matched slot. | ||
| 208 | bool was_pointer = false; | ||
| 209 | }; | ||
| 210 | |||
| 211 | /** | ||
| 212 | * @brief Self-heal one field offset after a layout shift. | ||
| 213 | * @details Checks the nominal slot (@c base + @c nominal_offset of @p lm) first. An unchanged offset returns | ||
| 214 | * at once and never trips the ambiguity test. On a nominal miss, the call scans the +/- @c window | ||
| 215 | * grid nearest-first in @c stride steps. It returns the uniquely nearest slot that resolves through | ||
| 216 | * @ref identify_pointee_type, satisfies @c indirection, and byte-equals @c expected_mangled. | ||
| 217 | * @return The healed offset and match details. @ref ErrorCode::BadDescriptor reports a malformed landmark | ||
| 218 | * before any read: a low @c base, an empty or oversized name, an unknown @c indirection, or a @c window | ||
| 219 | * over MAX_HEAL_WINDOW. A nominal address outside the user-mode window also counts as malformed. | ||
| 220 | * @ref ErrorCode::HealNoMatch reports that no slot matched. @ref ErrorCode::HealAmbiguous reports that | ||
| 221 | * both the @c +d and @c -d slots match at the nearest match distance. | ||
| 222 | * @warning Fail-wrong hazard: a strictly nearer same-typed decoy, or a secondary base, wins silently with a | ||
| 223 | * wrong offset. @ref ErrorCode::HealAmbiguous fires only for an exact +/- tie. For a crowded window, | ||
| 224 | * use @ref solve_fingerprint, @ref Indirection::CompleteObject, or a narrower @c window. | ||
| 225 | * @note Init-time or re-heal-on-miss only, not per frame: each probe runs the syscall-heavy prelude up to | ||
| 226 | * twice. Allocates nothing. | ||
| 227 | */ | ||
| 228 | [[nodiscard]] Result<HealHit> heal_landmark(const Landmark &lm) noexcept; | ||
| 229 | |||
| 230 | /** @brief Successful outcome from @ref solve_fingerprint. */ | ||
| 231 | struct FingerprintHit | ||
| 232 | { | ||
| 233 | /// The single uniform byte shift applied to every landmark offset. | ||
| 234 | std::ptrdiff_t delta = 0; | ||
| 235 | /// Required landmarks satisfied at @ref delta (equals the required count). | ||
| 236 | std::size_t matched = 0; | ||
| 237 | /// Optional landmarks also satisfied at @ref delta. | ||
| 238 | std::size_t optional_matched = 0; | ||
| 239 | }; | ||
| 240 | |||
| 241 | /** | ||
| 242 | * @brief Rigid multi-field drift recovery. | ||
| 243 | * @details Finds the single uniform delta in [-window_bytes, +window_bytes], in sizeof(std::uintptr_t) steps. | ||
| 244 | * At that delta, every required landmark at @p base + @c nominal_offset + delta reverse-resolves to | ||
| 245 | * its type and shape. Optional landmarks (@c required == false) only break ties between such deltas. | ||
| 246 | * @param base Struct base for every probe. The landmarks' own @c base fields are ignored. | ||
| 247 | * @param fp Landmark template. Each landmark's @c window and @c stride are ignored. | ||
| 248 | * @param window_bytes Largest uniform shift per side in bytes, at most MAX_HEAL_WINDOW. | ||
| 249 | * @return The recovered delta. @ref ErrorCode::BadDescriptor reports, before any memory read, an empty or | ||
| 250 | * over-cap span or no required landmark. It also reports, before any memory read, an oversized | ||
| 251 | * @p window_bytes, a malformed landmark, a duplicate @c nominal_offset, or a low @p base. | ||
| 252 | * @ref ErrorCode::HealNoMatch reports that no delta satisfied every required landmark. | ||
| 253 | * @ref ErrorCode::HealAmbiguous reports that two or more nonzero deltas tie for the most optional | ||
| 254 | * matches. A zero-drift delta that satisfies every required landmark wins a top-score tie outright, but | ||
| 255 | * a strictly higher optional score at any delta still wins. | ||
| 256 | * @warning Init-time only: the probe count is (2 * window_bytes / 8 + 1) * fp.size() prelude walks. Allocates | ||
| 257 | * nothing. | ||
| 258 | */ | ||
| 259 | [[nodiscard]] Result<FingerprintHit> | ||
| 260 | solve_fingerprint(Address base, std::span<const Landmark> fp, std::size_t window_bytes) noexcept; | ||
| 261 | |||
| 262 | /** @brief One landmark's heal outcome in a drift report, derived from its @ref heal_landmark result. */ | ||
| 263 | struct DriftEntry | ||
| 264 | { | ||
| 265 | /// Aliases the landmark's @c expected_mangled. | ||
| 266 | std::string_view name; | ||
| 267 | /// The landmark's last-known offset. | ||
| 268 | std::ptrdiff_t nominal_offset = 0; | ||
| 269 | /// The resolved offset (valid only when @ref ok). | ||
| 270 | std::ptrdiff_t healed_offset = 0; | ||
| 271 | /// healed_offset - nominal_offset (valid only when @ref ok). | ||
| 272 | std::ptrdiff_t delta = 0; | ||
| 273 | /// Whether the landmark healed. | ||
| 274 | bool ok = false; | ||
| 275 | /// Failure code in @ref ErrorCategory::Rtti, meaningful only when @ref ok is false. | ||
| 276 | ErrorCode error{ErrorCode::Ok}; | ||
| 277 | }; | ||
| 278 | |||
| 279 | /** | ||
| 280 | * @brief Heals a set of landmarks and writes a per-landmark drift report. | ||
| 281 | * @details Runs @ref heal_landmark on each landmark in order. Each landmark must have its @c base filled in. | ||
| 282 | * The call adds no reads over the individual heals and allocates nothing. | ||
| 283 | * @param out Destination, parallel to @p landmarks. | ||
| 284 | * @return The number of entries written: @c min(landmarks.size(), out.size()). | ||
| 285 | */ | ||
| 286 | [[nodiscard]] std::size_t heal_report(std::span<const Landmark> landmarks, std::span<DriftEntry> out) noexcept; | ||
| 287 | |||
| 288 | /** @brief Whether a healed-offset value can be consumed, and how strongly. */ | ||
| 289 | enum class OffsetValidity : std::uint8_t | ||
| 290 | { | ||
| 291 | /// A required heal missed. The retained value is not confirmed and has no established image generation. | ||
| 292 | Invalid = 0, | ||
| 293 | /** @brief An optional miss retained a nominal that is only usable as a hint. */ | ||
| 294 | Unverified = 1, | ||
| 295 | /// A heal resolved the offset with a nonzero image generation. | ||
| 296 | Confirmed = 2 | ||
| 297 | }; | ||
| 298 | |||
| 299 | /** @brief Consistent snapshot of a healed-offset slot: value, image generation, and validity. */ | ||
| 300 | struct HealedOffset | ||
| 301 | { | ||
| 302 | /** @brief The offset, meaningful for consumption only when validity is Confirmed. */ | ||
| 303 | std::ptrdiff_t value = 0; | ||
| 304 | /// @ref rtti::image_generation of the resolved vtable's image. Invalid and Unverified snapshots carry 0. | ||
| 305 | std::uint64_t generation = 0; | ||
| 306 | /// Whether @ref value can be consumed. | ||
| 307 | OffsetValidity validity = OffsetValidity::Invalid; | ||
| 308 | |||
| 309 | /// True only when the value is Confirmed and carries a nonzero image generation. | ||
| 310 | 6 | [[nodiscard]] bool usable() const noexcept | |
| 311 | { | ||
| 312 | 6 | return validity == OffsetValidity::Confirmed && generation != 0; | |
| 313 | } | ||
| 314 | }; | ||
| 315 | |||
| 316 | /** | ||
| 317 | * @brief Validity-bearing cross-thread channel for one healed offset. | ||
| 318 | * @details A consumer never blocks the producer or accepts a torn value. Hold it at a stable address. | ||
| 319 | */ | ||
| 320 | class HealedSlot | ||
| 321 | { | ||
| 322 | public: | ||
| 323 | HealedSlot() noexcept = default; | ||
| 324 | HealedSlot(const HealedSlot &) = delete; | ||
| 325 | HealedSlot &operator=(const HealedSlot &) = delete; | ||
| 326 | HealedSlot(HealedSlot &&) = delete; | ||
| 327 | HealedSlot &operator=(HealedSlot &&) = delete; | ||
| 328 | ~HealedSlot() noexcept = default; | ||
| 329 | |||
| 330 | /** @brief Seeds the slot with a nominal offset as @ref OffsetValidity::Unverified with generation 0. */ | ||
| 331 | void seed_nominal(std::ptrdiff_t nominal) noexcept; | ||
| 332 | |||
| 333 | /** | ||
| 334 | * @brief Publishes a snapshot atomically. Single producer only. | ||
| 335 | * @details A non-Confirmed state gets generation 0. Confirmed with generation 0 becomes Invalid. | ||
| 336 | */ | ||
| 337 | void publish(std::ptrdiff_t value, std::uint64_t generation, OffsetValidity validity) noexcept; | ||
| 338 | |||
| 339 | /// Returns a consistent snapshot, or Invalid if bounded retries cannot observe one. | ||
| 340 | [[nodiscard]] HealedOffset load() const noexcept; | ||
| 341 | |||
| 342 | /** | ||
| 343 | * @brief Returns the offset only when it is @ref OffsetValidity::Confirmed. | ||
| 344 | * @return The value, or @ref ErrorCode::OffsetNotConfirmed when validity or generation is absent. | ||
| 345 | * @note Callback-safe: a bounded seqlock read, with no allocation, lock, or I/O. | ||
| 346 | * @warning For mutation authorization tied to a module mapping, use the generation-checking overload. | ||
| 347 | */ | ||
| 348 | [[nodiscard]] Result<std::ptrdiff_t> authorized() const noexcept; | ||
| 349 | |||
| 350 | /** | ||
| 351 | * @brief Returns the offset only when it is Confirmed and still tied to @p current_generation. | ||
| 352 | * @param current_generation A nonzero, current @ref rtti::image_generation of the resolved type's module. | ||
| 353 | * @return The value, or @ref ErrorCode::OffsetNotConfirmed when the slot is not Confirmed or its generation | ||
| 354 | * is zero or differs from @p current_generation. | ||
| 355 | */ | ||
| 356 | [[nodiscard]] Result<std::ptrdiff_t> authorized(std::uint64_t current_generation) const noexcept; | ||
| 357 | |||
| 358 | private: | ||
| 359 | // Single-producer seqlock: even = stable, odd = write in progress. The sequence fences order the payload. | ||
| 360 | std::atomic<std::uint32_t> m_seq{0}; | ||
| 361 | std::atomic<std::ptrdiff_t> m_value{0}; | ||
| 362 | std::atomic<std::uint64_t> m_generation{0}; | ||
| 363 | std::atomic<std::uint8_t> m_validity{static_cast<std::uint8_t>(OffsetValidity::Invalid)}; | ||
| 364 | }; | ||
| 365 | |||
| 366 | /** @brief Log-severity policy a @ref HealScheduler applies to a landmark that does not resolve in a scan. */ | ||
| 367 | enum class HealEscalation : std::uint8_t | ||
| 368 | { | ||
| 369 | /// A required landmark that stays unresolved logs at Warning. An optional miss stays at Debug. The default. | ||
| 370 | WarnRequired = 0, | ||
| 371 | /// Every miss (required or optional) stays at Debug. | ||
| 372 | Quiet = 1 | ||
| 373 | }; | ||
| 374 | |||
| 375 | /** @brief Tunables for a @ref HealScheduler. */ | ||
| 376 | struct HealConfig | ||
| 377 | { | ||
| 378 | /** @brief Frames between retry scans of an un-latched group, which retries with no cap until it latches. */ | ||
| 379 | std::uint32_t interval_frames = 30; | ||
| 380 | /** | ||
| 381 | * @brief A realized drift whose absolute delta exceeds this value fires the one-shot layout-drift Warning. | ||
| 382 | * The default 0 warns on any nonzero drift. | ||
| 383 | */ | ||
| 384 | std::ptrdiff_t drift_warn_threshold = 0; | ||
| 385 | /// Log-severity policy for a landmark that does not resolve during a scan. | ||
| 386 | HealEscalation escalate = HealEscalation::WarnRequired; | ||
| 387 | }; | ||
| 388 | |||
| 389 | class HealScheduler; | ||
| 390 | |||
| 391 | /** | ||
| 392 | * @brief Per-scan heal context that a @ref HealScheduler passes to a group's work callback. | ||
| 393 | * @details A transient view over the scheduler's state, valid only during the callback. Do not store it. | ||
| 394 | */ | ||
| 395 | class HealRun | ||
| 396 | { | ||
| 397 | public: | ||
| 398 | HealRun(const HealRun &) = delete; | ||
| 399 | HealRun &operator=(const HealRun &) = delete; | ||
| 400 | HealRun(HealRun &&) = delete; | ||
| 401 | HealRun &operator=(HealRun &&) = delete; | ||
| 402 | |||
| 403 | /** | ||
| 404 | * @brief Heal one landmark from a live base and publish the result to a caller-owned offset slot. | ||
| 405 | * @details Runs @ref heal_landmark at @p base in place of the @c base of @p landmark. Only a resolved | ||
| 406 | * offset is stored, and a miss leaves @p slot untouched. The call logs confirmation, drift, or | ||
| 407 | * failure, with @p label as the field name. | ||
| 408 | * @param required Whether an unresolved miss escalates to Warning under @ref HealEscalation::WarnRequired. | ||
| 409 | * @return The @ref heal_landmark result. | ||
| 410 | * @warning A raw atomic carries no validity, so a required miss leaves a consumable nominal. This form is | ||
| 411 | * for read-only use. For writes or hooks, use the @ref HealedSlot overload. | ||
| 412 | */ | ||
| 413 | [[nodiscard]] Result<HealHit> heal_into( | ||
| 414 | std::string_view label, | ||
| 415 | const Landmark &landmark, | ||
| 416 | Address base, | ||
| 417 | std::atomic<std::ptrdiff_t> &slot, | ||
| 418 | bool required = true | ||
| 419 | ) noexcept; | ||
| 420 | |||
| 421 | /** | ||
| 422 | * @brief Validity-bearing form of @ref heal_into that publishes {value, generation, validity} to @p slot. | ||
| 423 | * @details A resolve publishes Confirmed with the nonzero image generation that brackets the re-established | ||
| 424 | * evidence. Any miss retains the value but publishes Invalid when @p required or Unverified when | ||
| 425 | * optional, so @ref HealedSlot::authorized rejects it. | ||
| 426 | * @param required Also escalates a miss log to Warning under @ref HealEscalation::WarnRequired. | ||
| 427 | * @return The @ref heal_landmark result, or @ref ErrorCode::OffsetNotConfirmed when the heal resolved but | ||
| 428 | * the vtable image generation was absent or changed, or the evidence changed. | ||
| 429 | */ | ||
| 430 | [[nodiscard]] Result<HealHit> heal_into( | ||
| 431 | std::string_view label, | ||
| 432 | const Landmark &landmark, | ||
| 433 | Address base, | ||
| 434 | HealedSlot &slot, | ||
| 435 | bool required = true | ||
| 436 | ) noexcept; | ||
| 437 | |||
| 438 | /** | ||
| 439 | * @brief Report a drift that a group recovered itself, for example through @ref solve_fingerprint. | ||
| 440 | * @details The one-shot Warning and the per-field Info line then fire as for @ref heal_into. After a group | ||
| 441 | * stores its shifted offsets, call it once per moved field. A zero delta logs a nominal | ||
| 442 | * confirmation at Debug and fires no Warning. | ||
| 443 | */ | ||
| 444 | void | ||
| 445 | note_drift(std::string_view label, std::ptrdiff_t nominal_offset, std::ptrdiff_t healed_offset) noexcept; | ||
| 446 | |||
| 447 | private: | ||
| 448 | friend class HealScheduler; | ||
| 449 | 355 | HealRun(const HealConfig &config, std::atomic<bool> &drift_warned) noexcept | |
| 450 | 355 | : m_config(config), m_drift_warned(drift_warned) | |
| 451 | { | ||
| 452 | 355 | } | |
| 453 | |||
| 454 | // Fires the one-shot drift Warning when |delta| exceeds the threshold and this call wins the CAS latch. | ||
| 455 | void warn_drift_once(std::string_view label, std::ptrdiff_t delta) noexcept; | ||
| 456 | |||
| 457 | const HealConfig &m_config; | ||
| 458 | std::atomic<bool> &m_drift_warned; | ||
| 459 | }; | ||
| 460 | |||
| 461 | /** | ||
| 462 | * @brief Frame-driven runner for a set of independently latched self-heal groups. | ||
| 463 | * @details The first drift over @c drift_warn_threshold fires the scheduler's one layout-drift Warning. | ||
| 464 | * @note Render-thread only, single owner. The offset slots a group writes are the cross-thread channel, not | ||
| 465 | * the scheduler. | ||
| 466 | */ | ||
| 467 | class HealScheduler | ||
| 468 | { | ||
| 469 | public: | ||
| 470 | /// Cheap per-frame precondition. A false result skips the group silently and does not spend the interval. | ||
| 471 | using Gate = std::move_only_function<bool()>; | ||
| 472 | /// A group's heal work. A true result latches the group. A false result retries at the next interval. | ||
| 473 | using Work = std::move_only_function<bool(HealRun &)>; | ||
| 474 | |||
| 475 | /** | ||
| 476 | * @brief Constructs a scheduler with @p config. | ||
| 477 | * @return The scheduler, @ref ErrorCode::InvalidArg for a zero interval or a negative drift threshold, or | ||
| 478 | * @ref ErrorCode::OutOfMemory when the allocation fails. | ||
| 479 | */ | ||
| 480 | [[nodiscard]] static Result<HealScheduler> start(HealConfig config = {}) noexcept; | ||
| 481 | |||
| 482 | HealScheduler(HealScheduler &&) noexcept; | ||
| 483 | HealScheduler &operator=(HealScheduler &&) noexcept; | ||
| 484 | HealScheduler(const HealScheduler &) = delete; | ||
| 485 | HealScheduler &operator=(const HealScheduler &) = delete; | ||
| 486 | ~HealScheduler() noexcept; | ||
| 487 | |||
| 488 | /** | ||
| 489 | * @brief Registers an independently latched heal group. | ||
| 490 | * @param work The group's heal work, run on the configured interval while un-latched. | ||
| 491 | * @param gate Optional per-frame precondition (see @ref Gate), run before the interval countdown. | ||
| 492 | * @return @ref ErrorCode::OutOfMemory when the registration allocation fails, with the scheduler unchanged. | ||
| 493 | * @note An empty @p work is ignored (no group is registered, reported as success). A re-entrant call from | ||
| 494 | * within @ref tick defers the new group to the next tick. A registered group counts from this call, | ||
| 495 | * so @ref all_resolved reports false until it latches. | ||
| 496 | * @note Setup/control-plane only: registration can allocate and mutate scheduler state. | ||
| 497 | */ | ||
| 498 | [[nodiscard]] Result<void> add_group(Work work, Gate gate = {}) noexcept; | ||
| 499 | |||
| 500 | /** | ||
| 501 | * @brief Advances the scheduler by one frame: scans every un-latched, gate-passing, interval-due group. | ||
| 502 | * @details A work or gate callback that throws counts as "did not resolve this frame". Deferred groups are | ||
| 503 | * adopted at tick exit. An adoption that failed on memory pressure is retried at the next tick's | ||
| 504 | * entry, so no tick count is lost. | ||
| 505 | */ | ||
| 506 | void tick() noexcept; | ||
| 507 | |||
| 508 | /** @brief Returns true when every registered group latched, deferred groups too, or when none exists. */ | ||
| 509 | [[nodiscard]] bool all_resolved() const noexcept; | ||
| 510 | |||
| 511 | /// Returns the config the scheduler was started with. | ||
| 512 | [[nodiscard]] const HealConfig &config() const noexcept; | ||
| 513 | |||
| 514 | private: | ||
| 515 | struct Impl; | ||
| 516 | explicit HealScheduler(std::unique_ptr<Impl> impl) noexcept; | ||
| 517 | std::unique_ptr<Impl> m_impl; | ||
| 518 | }; | ||
| 519 | } // namespace rtti | ||
| 520 | } // namespace DetourModKit | ||
| 521 | |||
| 522 | #endif // DETOURMODKIT_RTTI_DISSECT_HPP | ||
| 523 |