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

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
5/6
std::expected<void, DetourModKit::Error> DetourModKit::rtti::identify_pointee_type_or<>(DetourModKit::Address, DetourModKit::rtti::PointeeType&):
✓ Branch 4 → 5 taken 2 times.
✓ Branch 4 → 6 taken 1 time.
std::expected<void, DetourModKit::Error> DetourModKit::rtti::identify_pointee_type_or<DetourModKit::Address>(DetourModKit::Address, DetourModKit::rtti::PointeeType&, DetourModKit::Address):
✓ Branch 4 → 5 taken 1 time.
✓ Branch 4 → 6 taken 1 time.
std::expected<void, DetourModKit::Error> DetourModKit::rtti::identify_pointee_type_or<DetourModKit::Address, DetourModKit::Address>(DetourModKit::Address, DetourModKit::rtti::PointeeType&, DetourModKit::Address, DetourModKit::Address):
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 2 times.
7 if (primary)
102 {
103 3 return {};
104 }
105
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() || ...);
106
3/4
std::expected<void, DetourModKit::Error> DetourModKit::rtti::identify_pointee_type_or<DetourModKit::Address>(DetourModKit::Address, DetourModKit::rtti::PointeeType&, DetourModKit::Address):
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 1 time.
std::expected<void, DetourModKit::Error> DetourModKit::rtti::identify_pointee_type_or<DetourModKit::Address, DetourModKit::Address>(DetourModKit::Address, DetourModKit::rtti::PointeeType&, DetourModKit::Address, DetourModKit::Address):
✓ Branch 14 → 15 taken 1 time.
✓ Branch 14 → 16 taken 1 time.
3 if (any)
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
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;
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