include/DetourModKit/rtti.hpp
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | #ifndef DETOURMODKIT_RTTI_HPP | ||
| 2 | #define DETOURMODKIT_RTTI_HPP | ||
| 3 | |||
| 4 | #include "DetourModKit/region.hpp" | ||
| 5 | |||
| 6 | #include <atomic> | ||
| 7 | #include <cstddef> | ||
| 8 | #include <cstdint> | ||
| 9 | #include <optional> | ||
| 10 | #include <string> | ||
| 11 | #include <string_view> | ||
| 12 | |||
| 13 | namespace DetourModKit | ||
| 14 | { | ||
| 15 | /** | ||
| 16 | * @namespace DetourModKit::rtti | ||
| 17 | * @brief MSVC RTTI introspection primitives. | ||
| 18 | * @details Walks the x64 MSVC COL/TypeDescriptor layout to recover the mangled type-descriptor name for a runtime | ||
| 19 | * object. The implementation operates on raw addresses and never invokes typeid() or dynamic_cast, so it | ||
| 20 | * works across DLL boundaries against third-party MSVC binaries. Every entry point except the allocating | ||
| 21 | * TypeIdentity constructor is noexcept and guarded: an unreadable page, missing COL, or zero RVA produces | ||
| 22 | * a failure return. Names are returned in | ||
| 23 | * the MSVC mangled form (for example ".?AVMyClass@ns@@") for exact byte-equal comparison. | ||
| 24 | * | ||
| 25 | * When the host binary is compiled with RTTI disabled (/GR-), the TypeDescriptor records are not emitted | ||
| 26 | * and every RTTI-based resolver returns its fail-closed sentinel rather than a fault or a wrong answer. | ||
| 27 | * The raw-byte fallbacks are @ref scan::find_string_xref and @ref scan::read_code_constant. Only | ||
| 28 | * @ref RttiPresence::Absent proves a complete records-free sweep. The failure-mode discussion is in | ||
| 29 | * docs/guides/rtti/rtti-walker.md and docs/guides/rtti/rtti-self-heal.md. | ||
| 30 | * @warning `[B-100]` Under the loader lock, call @ref TypeIdentity::matches only after it is warm, or use another | ||
| 31 | * Callback-safe entry point. Cold identity paths and setup routes can query the loader or scan an image. | ||
| 32 | */ | ||
| 33 | namespace rtti | ||
| 34 | { | ||
| 35 | /// Default cap on the mangled-name length read into a heap-allocated string. | ||
| 36 | inline constexpr std::size_t DEFAULT_TYPE_NAME_MAX = 256; | ||
| 37 | |||
| 38 | /// Hard upper bound on any single mangled-name read. | ||
| 39 | inline constexpr std::size_t MAX_TYPE_NAME_LEN = 1024; | ||
| 40 | |||
| 41 | /** | ||
| 42 | * @enum Traversal | ||
| 43 | * @brief Completeness of a reverse-RTTI section/page sweep. | ||
| 44 | * @details A reverse resolver answers "is there a unique vtable for this type" or "does this scope hold any | ||
| 45 | * record" by sweeping the module's readable non-executable sections. A verdict that depends on having | ||
| 46 | * seen the WHOLE image, such as a unique vtable or an authoritative absence, is trustworthy only under | ||
| 47 | * @ref Complete. A truncated sweep can hide a second primary (false uniqueness) or the only record | ||
| 48 | * (false absence), so the checked reverse forms surface this rather than reporting a positive prefix | ||
| 49 | * as final. | ||
| 50 | */ | ||
| 51 | enum class Traversal : std::uint8_t | ||
| 52 | { | ||
| 53 | /// Every qualifying section was enumerated and every page in it was read. | ||
| 54 | Complete = 0, | ||
| 55 | /** @brief The sweep under-covered the image, so a unique or absent verdict cannot be authorized. */ | ||
| 56 | Incomplete = 1, | ||
| 57 | /** @brief The internal fixed buffer filled, so unseen qualifying sections or matches may exist. */ | ||
| 58 | Saturated = 2 | ||
| 59 | }; | ||
| 60 | |||
| 61 | /** | ||
| 62 | * @enum NameStatus | ||
| 63 | * @brief Outcome of a checked mangled-name read (@ref type_name_checked). | ||
| 64 | */ | ||
| 65 | enum class NameStatus : std::uint8_t | ||
| 66 | { | ||
| 67 | /// The full NUL-terminated name was copied. | ||
| 68 | Ok = 0, | ||
| 69 | /** @brief The NUL-terminated copy is a proper prefix and must not be compared for identity. */ | ||
| 70 | Truncated = 1, | ||
| 71 | /// No name was read (null/low vtable, missing or forged COL, unreadable page). | ||
| 72 | Failed = 2 | ||
| 73 | }; | ||
| 74 | |||
| 75 | /** | ||
| 76 | * @struct NameRead | ||
| 77 | * @brief Result of @ref type_name_checked: bytes written plus whether the copy is the complete name. | ||
| 78 | */ | ||
| 79 | struct NameRead | ||
| 80 | { | ||
| 81 | /// Name bytes written excluding the NUL terminator. | ||
| 82 | std::size_t written = 0; | ||
| 83 | /// Whether the copy is complete, a truncated prefix, or a failure. | ||
| 84 | NameStatus status = NameStatus::Failed; | ||
| 85 | }; | ||
| 86 | |||
| 87 | /** | ||
| 88 | * @struct VtablesResult | ||
| 89 | * @brief Result of @ref vtables_for_type_checked: the match count plus the sweep completeness. | ||
| 90 | */ | ||
| 91 | struct VtablesResult | ||
| 92 | { | ||
| 93 | /// Distinct matching sub-object vtables found (the same value @ref vtables_for_type returns). | ||
| 94 | std::size_t count = 0; | ||
| 95 | /** @brief Sweep completeness; under Incomplete or Saturated, count is only a floor. */ | ||
| 96 | Traversal completeness = Traversal::Complete; | ||
| 97 | }; | ||
| 98 | |||
| 99 | /** | ||
| 100 | * @enum RttiPresence | ||
| 101 | * @brief Trit answer of @ref region_rtti_presence, separating an authoritative absence from an incomplete | ||
| 102 | * sweep. | ||
| 103 | */ | ||
| 104 | enum class RttiPresence : std::uint8_t | ||
| 105 | { | ||
| 106 | /// At least one resolvable RTTI record was found (sound regardless of completeness: a hit is a hit). | ||
| 107 | Present = 0, | ||
| 108 | /// The sweep completed and found no record: an authoritative absence (an MSVC /GR- scope, a data module). | ||
| 109 | Absent = 1, | ||
| 110 | /** @brief The sweep did not complete, so absence cannot be concluded. */ | ||
| 111 | Incomplete = 2 | ||
| 112 | }; | ||
| 113 | |||
| 114 | /** | ||
| 115 | * @brief Reads the MSVC RTTI mangled type-descriptor name for the object whose runtime vtable is at @p vtable. | ||
| 116 | * @details Walks vtable[-1] to the COL, the TypeDescriptor RVA, and the zero-terminated mangled name. The | ||
| 117 | * @c col.pSelf cross-check rejects a forged or relocated COL, and any signature other than the x64 | ||
| 118 | * value is rejected. Reads are page-bounded and guarded. The first NUL terminates the result. | ||
| 119 | * @param vtable Runtime vtable pointer (the first qword of the object). | ||
| 120 | * @param max_len Maximum mangled-name length to copy; clamped to @ref MAX_TYPE_NAME_LEN. Zero is replaced with | ||
| 121 | * @ref DEFAULT_TYPE_NAME_MAX. | ||
| 122 | * @return The mangled name on success, std::nullopt on any failure (null vtable, unmapped page, missing COL, | ||
| 123 | * bad RVA, allocation failure). | ||
| 124 | * @note Performs one heap allocation for the returned std::string. For per-frame identity probes use @ref | ||
| 125 | * vtable_is_type or @ref type_name_into to avoid the allocation. | ||
| 126 | * @note Setup/control-plane only: the read allocates and runs the loader-querying COL prelude. | ||
| 127 | */ | ||
| 128 | [[nodiscard]] std::optional<std::string> | ||
| 129 | type_name_of(Address vtable, std::size_t max_len = DEFAULT_TYPE_NAME_MAX) noexcept; | ||
| 130 | |||
| 131 | /** | ||
| 132 | * @brief Zero-allocation form of @ref type_name_of. | ||
| 133 | * @details Writes the mangled name into @p out (always NUL-terminated when @p out_len > 0) and returns the | ||
| 134 | * number of bytes written excluding the terminator. On any failure the output buffer's first byte is | ||
| 135 | * set to '\0' and 0 is returned. | ||
| 136 | * @param vtable Runtime vtable pointer (the first qword of the object). | ||
| 137 | * @param out Destination buffer. Must be non-null when @p out_len > 0. | ||
| 138 | * @param out_len Capacity of @p out including the NUL terminator. The function never writes more than @p | ||
| 139 | * out_len bytes. | ||
| 140 | * @return Number of name bytes written (excluding the NUL terminator), or 0 on failure or empty output. | ||
| 141 | * @note Setup/control-plane only: each call runs the COL prelude, which queries the loader. Cache a | ||
| 142 | * @ref TypeIdentity for a per-frame check. | ||
| 143 | */ | ||
| 144 | [[nodiscard]] std::size_t type_name_into(Address vtable, char *out, std::size_t out_len) noexcept; | ||
| 145 | |||
| 146 | /** | ||
| 147 | * @brief Truncation-reporting form of @ref type_name_into. | ||
| 148 | * @details Writes the mangled name into @p out exactly as @ref type_name_into, but reports | ||
| 149 | * @ref NameStatus::Truncated whenever the real name did not fit @p out or the @ref MAX_TYPE_NAME_LEN | ||
| 150 | * hard cap, so an identity comparison can reject a truncated read instead of matching a prefix. | ||
| 151 | * @param vtable Runtime vtable pointer (the first qword of the object). | ||
| 152 | * @param out Destination buffer; always NUL-terminated when @p out_len > 0. Must be non-null when @p | ||
| 153 | * out_len > 0. | ||
| 154 | * @param out_len Capacity of @p out including the NUL terminator. | ||
| 155 | * @return @ref NameRead::written name bytes (excluding the NUL) and a @ref NameRead::status of @ref | ||
| 156 | * NameStatus::Ok (complete), @ref NameStatus::Truncated (a prefix; do not compare for identity), or | ||
| 157 | * @ref NameStatus::Failed (nothing read; @p out is left empty). | ||
| 158 | * @note Setup/control-plane only (see @ref type_name_into). | ||
| 159 | */ | ||
| 160 | [[nodiscard]] NameRead type_name_checked(Address vtable, char *out, std::size_t out_len) noexcept; | ||
| 161 | |||
| 162 | /** | ||
| 163 | * @brief A stable, mapping-scoped identity token for the module currently mapped over @p addr. | ||
| 164 | * @details Folds the image base, SizeOfImage, PE TimeDateStamp, and the section table into a 64-bit token, | ||
| 165 | * read through the guarded engine. The token is stable while one image stays mapped and changes when | ||
| 166 | * a same-base replacement changes an identity-bearing PE header field. It carries the same | ||
| 167 | * discrimination as @ref scan::image_identity. @ref TypeIdentity keys its cached resolve on it, and a | ||
| 168 | * @ref HealedOffset consumer compares @ref HealedOffset::generation against it. | ||
| 169 | * @param addr Any address inside the module of interest (typically a module base or a live object pointer). | ||
| 170 | * @return A nonzero identity token for a module-backed address; 0 when @p addr is not inside any loaded module | ||
| 171 | * (an unmapped address or a private @c VirtualAlloc buffer carries no module-backed identity to track). | ||
| 172 | * @note Setup/control-plane only: resolves the owning module through the loader before reading its PE header. | ||
| 173 | * @warning Like @ref scan::image_identity, this is layout identity rather than content identity. A replacement | ||
| 174 | * that preserves every folded header field while changing only section bytes remains invisible. | ||
| 175 | */ | ||
| 176 | [[nodiscard]] std::uint64_t image_generation(Address addr) noexcept; | ||
| 177 | |||
| 178 | /** | ||
| 179 | * @brief Tests whether the MSVC RTTI mangled name for @p vtable equals @p expected exactly. | ||
| 180 | * @details Performs a byte-exact comparison of the mangled name plus the terminating NUL, rejecting both proper | ||
| 181 | * prefix and substring matches. The read is bounded by the length of @p expected plus one byte, so no | ||
| 182 | * allocation occurs and the per-call cost is dominated by the SEH-guarded read of @p expected.size() + | ||
| 183 | * 1 bytes from the name buffer. | ||
| 184 | * @param vtable Runtime vtable pointer. | ||
| 185 | * @param expected Mangled name to compare against. Must be non-empty and shorter than @ref MAX_TYPE_NAME_LEN. | ||
| 186 | * @return true on exact match; false on mismatch, on any read failure, or when @p expected is empty or | ||
| 187 | * oversized. | ||
| 188 | * @note Setup/control-plane only: each call runs the COL prelude, which queries the loader. | ||
| 189 | * @ref TypeIdentity::matches is the per-frame route. | ||
| 190 | */ | ||
| 191 | [[nodiscard]] bool vtable_is_type(Address vtable, std::string_view expected) noexcept; | ||
| 192 | |||
| 193 | /** | ||
| 194 | * @class PointerTableCache | ||
| 195 | * @brief Generation-bearing cache for repeated @ref find_in_pointer_table calls with one expected type. | ||
| 196 | * @details Stores the resolved vtable together with its image base and generation. Concurrent reads are | ||
| 197 | * supported; publication is non-blocking, and a competing publisher leaves the existing snapshot for | ||
| 198 | * the next call to validate. | ||
| 199 | */ | ||
| 200 | class PointerTableCache | ||
| 201 | { | ||
| 202 | public: | ||
| 203 | /// Constructs an empty cache. | ||
| 204 | PointerTableCache() noexcept = default; | ||
| 205 | PointerTableCache(const PointerTableCache &) = delete; | ||
| 206 | PointerTableCache &operator=(const PointerTableCache &) = delete; | ||
| 207 | PointerTableCache(PointerTableCache &&) = delete; | ||
| 208 | PointerTableCache &operator=(PointerTableCache &&) = delete; | ||
| 209 | ~PointerTableCache() noexcept = default; | ||
| 210 | |||
| 211 | /** | ||
| 212 | * @brief Clears the cached identity so the next lookup starts cold. | ||
| 213 | * @note Setup/control-plane only: waits for an in-progress cache publication to finish. | ||
| 214 | */ | ||
| 215 | void reset() noexcept; | ||
| 216 | |||
| 217 | private: | ||
| 218 | friend std::optional<Address> find_in_pointer_table( | ||
| 219 | Address table, | ||
| 220 | std::size_t slot_count, | ||
| 221 | std::string_view expected, | ||
| 222 | PointerTableCache &cache, | ||
| 223 | std::size_t stride | ||
| 224 | ) noexcept; | ||
| 225 | |||
| 226 | // Single-writer sequence protects a coherent {vtable, image base, generation} snapshot. | ||
| 227 | std::atomic_flag m_writer{}; | ||
| 228 | std::atomic<std::uint32_t> m_seq{0}; | ||
| 229 | std::atomic<Address> m_vtable{Address{}}; | ||
| 230 | std::atomic<Address> m_image_base{Address{}}; | ||
| 231 | std::atomic<std::uint64_t> m_generation{0}; | ||
| 232 | // Advanced by reset() so a lookup that started earlier cannot publish across the reset boundary. | ||
| 233 | std::atomic<std::uint64_t> m_epoch{0}; | ||
| 234 | }; | ||
| 235 | |||
| 236 | /** | ||
| 237 | * @brief Scans a pointer-table for the first slot whose object has the given RTTI type-descriptor name. | ||
| 238 | * @details Treats @p table as an array of @p slot_count entries each @p stride bytes wide. A cold cache (or a | ||
| 239 | * nullptr @p vtable_cache) walks RTTI per slot via @ref vtable_is_type. A warm cache compares each | ||
| 240 | * slot against the cached vtable. If no slot carries it, the stale value is cleared and one cold pass | ||
| 241 | * runs. A cold-path match refreshes @p vtable_cache. The cache shape is one std::atomic<Address> per | ||
| 242 | * expected name. A null Address encodes "cold". | ||
| 243 | * @param table Base address of the pointer table. | ||
| 244 | * @param slot_count Number of slots to scan. | ||
| 245 | * @param expected Mangled name to match. | ||
| 246 | * @param vtable_cache Optional caller-owned cache slot. Pass nullptr to skip caching (every call walks RTTI). | ||
| 247 | * @param stride Byte distance between adjacent slot addresses. Defaults to sizeof(std::uintptr_t) for a packed | ||
| 248 | * pointer array; pass a larger stride for tables that interleave per-slot metadata between | ||
| 249 | * pointers. | ||
| 250 | * @return The object pointer (the value stored in the slot) on first match, or std::nullopt if no slot matched. | ||
| 251 | * @note The cold path walks RTTI for each slot. A warm cache costs two guarded reads and one compare per slot. | ||
| 252 | * @warning The warm-cache path assumes one canonical vtable address per expected name. If multiple derived | ||
| 253 | * concrete classes share the same base-mangled name and the table holds a mix of them, only slots | ||
| 254 | * whose vtable equals the first-resolved instance are returned on the warm path. Other matches are | ||
| 255 | * skipped. For MSVC RTTI this is correct: mangled names encode the most-derived class, not the base. | ||
| 256 | * @warning This compatibility overload's raw atomic carries no image generation. Clear it at module-lifecycle | ||
| 257 | * boundaries, or use the @ref PointerTableCache overload for generation-checked caching. | ||
| 258 | * @note Callback-safe on the warm-cache path (guarded reads and compares). A cold or stale cache walks RTTI | ||
| 259 | * through the loader-querying prelude, which is setup/control-plane work. | ||
| 260 | */ | ||
| 261 | [[nodiscard]] std::optional<Address> find_in_pointer_table( | ||
| 262 | Address table, | ||
| 263 | std::size_t slot_count, | ||
| 264 | std::string_view expected, | ||
| 265 | std::atomic<Address> *vtable_cache = nullptr, | ||
| 266 | std::size_t stride = sizeof(std::uintptr_t) | ||
| 267 | ) noexcept; | ||
| 268 | |||
| 269 | /** | ||
| 270 | * @brief Generation-checked overload of @ref find_in_pointer_table. | ||
| 271 | * @details A warm snapshot is accepted only while its image generation remains current. A warm call reads the | ||
| 272 | * image-generation token twice, once before the slot sweep and once before it returns. A stale | ||
| 273 | * snapshot is cleared and cold-resolved. Publication revalidates the type and generation before it | ||
| 274 | * caches them. | ||
| 275 | * @param table Base address of the pointer table. | ||
| 276 | * @param slot_count Number of slots to scan. | ||
| 277 | * @param expected Mangled name to match; one cache instance is dedicated to one expected name. | ||
| 278 | * @param cache Caller-owned generation-bearing cache. | ||
| 279 | * @param stride Byte distance between adjacent slot addresses. | ||
| 280 | * @return The first matching object pointer, or std::nullopt. | ||
| 281 | * @note Prefer this overload when the cache survives module unload/reload boundaries. | ||
| 282 | * @note Callback-safe on the warm-cache path; a cold or stale cache is setup/control-plane work (see the | ||
| 283 | * compatibility overload). | ||
| 284 | */ | ||
| 285 | [[nodiscard]] std::optional<Address> find_in_pointer_table( | ||
| 286 | Address table, | ||
| 287 | std::size_t slot_count, | ||
| 288 | std::string_view expected, | ||
| 289 | PointerTableCache &cache, | ||
| 290 | std::size_t stride = sizeof(std::uintptr_t) | ||
| 291 | ) noexcept; | ||
| 292 | |||
| 293 | /** | ||
| 294 | * @brief Resolves the primary (most-derived) vtable for a class by its | ||
| 295 | * MSVC mangled name, scoped to one module image. | ||
| 296 | * @details Sweeps the module's readable, non-executable sections for a COL whose TypeDescriptor name equals | ||
| 297 | * @p mangled and whose COL.offset is 0, and returns the vtable that points back to that COL. Every | ||
| 298 | * candidate passes the same COL prelude the forward walker uses, so a forged or coincidental match is | ||
| 299 | * rejected. COL.offset == 0 selects the most-derived instance's vtable. For a class used only as a | ||
| 300 | * secondary or virtual base, use @ref vtables_for_type. | ||
| 301 | * @param mangled Exact MSVC mangled name (e.g. ".?AVMyClass@ns@@"). | ||
| 302 | * @param range Module image to search. Defaults to the host EXE. The scope is required because the same mangled | ||
| 303 | * name can appear in several loaded modules and COL RVAs are image-base-relative. | ||
| 304 | * @return The primary vtable on a unique match; std::nullopt on absence, invalid scope, ambiguous primaries, or | ||
| 305 | * incomplete traversal. A partial sweep cannot authorize uniqueness because a second primary may be in | ||
| 306 | * the un-swept region. Use @ref vtables_for_type_checked to distinguish absence from an incomplete | ||
| 307 | * traversal. | ||
| 308 | * @note Setup/control-plane only: it sweeps the module's readable sections, so run it once at init (or behind a | ||
| 309 | * cached @ref TypeIdentity), never per-frame. | ||
| 310 | */ | ||
| 311 | [[nodiscard]] std::optional<Address> | ||
| 312 | vtable_for_type(std::string_view mangled, Region range = Region::host()) noexcept; | ||
| 313 | |||
| 314 | /** | ||
| 315 | * @brief Collects every sub-object vtable sharing a class's mangled name. | ||
| 316 | * @details Multiple or virtual inheritance gives one class several COLs, one per base sub-object, each | ||
| 317 | * referenced by its own vtable. This returns all of them. Each match is validated through the COL | ||
| 318 | * prelude exactly as @ref vtable_for_type. | ||
| 319 | * @param mangled Exact MSVC mangled name. | ||
| 320 | * @param out Destination buffer for the matching vtable addresses, written in ascending COL.offset order (the | ||
| 321 | * primary, offset 0, first). May be nullptr only when @p out_cap is 0 (count-only query). | ||
| 322 | * @param out_cap Capacity of @p out; at most @p out_cap addresses are written even when more matches exist. | ||
| 323 | * @param range Module image to search. Defaults to the host EXE. | ||
| 324 | * @return Number of distinct matching vtables found (capped at an internal upper bound that far exceeds any | ||
| 325 | * real inheritance graph). A return value greater than @p out_cap signals the output was truncated. | ||
| 326 | * An incomplete or saturated sweep makes the count a lower bound; a caller that needs an authoritative | ||
| 327 | * total uses @ref vtables_for_type_checked. | ||
| 328 | * @note Setup/control-plane only (see @ref vtable_for_type). | ||
| 329 | */ | ||
| 330 | [[nodiscard]] std::size_t vtables_for_type( | ||
| 331 | std::string_view mangled, | ||
| 332 | Address *out, | ||
| 333 | std::size_t out_cap, | ||
| 334 | Region range = Region::host() | ||
| 335 | ) noexcept; | ||
| 336 | |||
| 337 | /** | ||
| 338 | * @brief Completeness-reporting form of @ref vtables_for_type. | ||
| 339 | * @param mangled Exact MSVC mangled name. | ||
| 340 | * @param out Destination buffer for the matching vtable addresses, ascending COL.offset order (primary first). | ||
| 341 | * May be nullptr only when @p out_cap is 0 (count-only query). | ||
| 342 | * @param out_cap Capacity of @p out; at most @p out_cap addresses are written even when more matches exist. | ||
| 343 | * @param range Module image to search. Defaults to the host EXE. | ||
| 344 | * @return The distinct-match @ref VtablesResult::count (a @ref VtablesResult::completeness other than @ref | ||
| 345 | * Traversal::Complete means the count is a floor, not the authoritative total). | ||
| 346 | * @note Setup/control-plane only (see @ref vtable_for_type). | ||
| 347 | */ | ||
| 348 | [[nodiscard]] VtablesResult vtables_for_type_checked( | ||
| 349 | std::string_view mangled, | ||
| 350 | Address *out, | ||
| 351 | std::size_t out_cap, | ||
| 352 | Region range = Region::host() | ||
| 353 | ) noexcept; | ||
| 354 | |||
| 355 | /** | ||
| 356 | * @brief Reports whether a module region currently contains any resolvable MSVC RTTI record. | ||
| 357 | * @details Sweeps @p range for any COL that passes the reverse resolver's validation checks. The two answers | ||
| 358 | * are asymmetric: | ||
| 359 | * - true is sound but only proves SOME record exists, not that the caller's type resolves (a /GR- | ||
| 360 | * executable that links a /GR CRT returns true off those library COLs); | ||
| 361 | * - false means "no record was found in what was swept" and is not by itself proof of absence. Use | ||
| 362 | * @ref region_rtti_presence when absence versus an incomplete sweep matters. | ||
| 363 | * @param range Module image to inspect. Defaults to the host EXE. | ||
| 364 | * @return true if @p range holds at least one resolvable RTTI record; false if none was found in the swept | ||
| 365 | * portion or @p range is not a valid mapped image. | ||
| 366 | * @note Setup/control-plane only (see @ref vtable_for_type). It carries no re-sweep throttle, so a | ||
| 367 | * records-free scope pays a full sweep on every call. | ||
| 368 | * @note An absent verdict on a still-packed image is a transient truth about the CURRENT mapping, not proof the | ||
| 369 | * binary was built /GR-; re-inspect after the image unpacks rather than caching the result as permanent. | ||
| 370 | */ | ||
| 371 | [[nodiscard]] bool region_has_rtti(Region range = Region::host()) noexcept; | ||
| 372 | |||
| 373 | /** | ||
| 374 | * @brief Completeness-reporting form of @ref region_has_rtti. | ||
| 375 | * @param range Module image to inspect. Defaults to the host EXE. | ||
| 376 | * @return @ref RttiPresence::Present, @ref RttiPresence::Absent, or @ref RttiPresence::Incomplete. An invalid | ||
| 377 | * @p range reports Incomplete. | ||
| 378 | * @note Setup/control-plane only (see @ref vtable_for_type). | ||
| 379 | */ | ||
| 380 | [[nodiscard]] RttiPresence region_rtti_presence(Region range = Region::host()) noexcept; | ||
| 381 | |||
| 382 | /** | ||
| 383 | * @brief Cached, self-healing, generation-aware identity handle for a class vtable. | ||
| 384 | * @details Resolves the primary vtable for a mangled name lazily via @ref vtable_for_type and caches it. A | ||
| 385 | * module-backed resolve is published only when the image generation is stable across the sweep. The | ||
| 386 | * warm path re-validates that stamp on every call and refreshes the full module extent after a remap. | ||
| 387 | * @ref invalidate forces an immediate cold resolve. A private-buffer scope has no module generation | ||
| 388 | * and must be reset explicitly. | ||
| 389 | * @note Take identity from the cached vtable ADDRESS (the vtable[-1] | ||
| 390 | * COL-anchored value), never from the vtable's slot contents: under the MSVC linker's identical-COMDAT | ||
| 391 | * folding (/OPT:ICF) two distinct classes can share folded function-pointer slots, so a slot-content | ||
| 392 | * comparison is not class-unique. | ||
| 393 | * @note Owns its mangled name (a private std::string copy), so no lifetime coupling to the caller's buffer. | ||
| 394 | * Non-copyable and non-movable. Hold it as a static or a long-lived member. | ||
| 395 | */ | ||
| 396 | class TypeIdentity | ||
| 397 | { | ||
| 398 | public: | ||
| 399 | /** | ||
| 400 | * @brief Constructs a cached identity for @p mangled, scoped to @p range. | ||
| 401 | * @details Construction allocates the owned name copy and can throw std::bad_alloc. | ||
| 402 | * @param mangled Exact MSVC mangled name. Copied into owned storage. | ||
| 403 | * @param range Module image to resolve in. Defaults to the host EXE. | ||
| 404 | * @note Setup/control-plane only: cache construction allocates. | ||
| 405 | */ | ||
| 406 | explicit TypeIdentity(std::string_view mangled, Region range = Region::host()); | ||
| 407 | |||
| 408 | TypeIdentity(const TypeIdentity &) = delete; | ||
| 409 | TypeIdentity &operator=(const TypeIdentity &) = delete; | ||
| 410 | TypeIdentity(TypeIdentity &&) = delete; | ||
| 411 | TypeIdentity &operator=(TypeIdentity &&) = delete; | ||
| 412 | 14 | ~TypeIdentity() noexcept = default; | |
| 413 | |||
| 414 | /** | ||
| 415 | * @brief Tests whether @p vtable is this type's primary vtable. | ||
| 416 | * @details Resolves on first call, then compares. Returns false when the type cannot be resolved, so a | ||
| 417 | * missing type never matches. | ||
| 418 | * @param vtable Candidate vtable (an object's first qword). | ||
| 419 | * @return true when @p vtable equals the resolved primary vtable. | ||
| 420 | * @note Callback-safe once warm: the generation check performs bounded guarded PE-header reads; a changed | ||
| 421 | * image triggers a setup-cost resolve. | ||
| 422 | */ | ||
| 423 | [[nodiscard]] bool matches(Address vtable) const noexcept; | ||
| 424 | |||
| 425 | /** | ||
| 426 | * @brief Returns the resolved primary vtable, resolving on first use. | ||
| 427 | * @return The vtable address, or std::nullopt if it cannot be resolved in the configured module range. | ||
| 428 | * @note Callback-safe once warm: the first call resolves (a setup-cost module sweep), and a successful | ||
| 429 | * result is cached. An unresolved result is not cached, but the re-sweep is throttled to at most | ||
| 430 | * once per internal cooldown, so per-frame polling for an absent type does not re-scan the module | ||
| 431 | * each frame. | ||
| 432 | */ | ||
| 433 | [[nodiscard]] std::optional<Address> vtable() const noexcept; | ||
| 434 | |||
| 435 | /** | ||
| 436 | * @brief Drops the cached resolve so the next @ref vtable / @ref matches re-resolves from scratch. | ||
| 437 | * @details Idempotent and safe to call at any time. Use it when a consumer knows the resolving module was | ||
| 438 | * unloaded or reloaded. Does not change the mangled name or range the handle was constructed with. | ||
| 439 | * @note Setup/control-plane only: waits for an in-progress cache publication to finish; never throws. | ||
| 440 | */ | ||
| 441 | void invalidate() noexcept; | ||
| 442 | |||
| 443 | private: | ||
| 444 | std::string m_mangled; | ||
| 445 | Region m_range; | ||
| 446 | bool m_tracks_module_range{false}; | ||
| 447 | |||
| 448 | // m_cached holds the resolved primary vtable and is written only on a SUCCESSFUL (non-null) resolve. | ||
| 449 | // m_resolved latches that success and is published with release after m_cached is stored, so an | ||
| 450 | // acquire-load that observes m_resolved == true also observes the cached value. A failed resolve latches | ||
| 451 | // neither flag, so a later call retries once the type becomes resolvable instead of caching the miss as | ||
| 452 | // permanent. | ||
| 453 | mutable std::atomic<Address> m_cached{Address{}}; | ||
| 454 | mutable std::atomic<bool> m_resolved{false}; | ||
| 455 | |||
| 456 | // The resolving module's image_generation at the last successful resolve (0 = none, or a non-module range). | ||
| 457 | // The warm path re-reads the current generation and drops the cache when it differs, so an unload or a | ||
| 458 | // detectable same-base remap invalidates the cached vtable instead of matching against a module that is | ||
| 459 | // no longer mapped. | ||
| 460 | mutable std::atomic<std::uint64_t> m_image_stamp{0}; | ||
| 461 | mutable std::atomic<Address> m_image_base{Address{}}; | ||
| 462 | |||
| 463 | // Serializes the short publish/clear transaction; the RTTI sweep itself runs without holding it. | ||
| 464 | mutable std::atomic_flag m_cache_writer{}; | ||
| 465 | // Incremented whenever the cache is cleared so a resolve already in flight cannot republish afterward. | ||
| 466 | mutable std::atomic<std::uint64_t> m_cache_epoch{0}; | ||
| 467 | |||
| 468 | // Millisecond timestamp of the last resolve attempt that controls a later retry (0 = never). It bounds | ||
| 469 | // whole-module retries. Successful warm calls do not modify it. | ||
| 470 | mutable std::atomic<std::uint64_t> m_last_attempt_ms{0}; | ||
| 471 | }; | ||
| 472 | } // namespace rtti | ||
| 473 | } // namespace DetourModKit | ||
| 474 | |||
| 475 | #endif // DETOURMODKIT_RTTI_HPP | ||
| 476 |