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 | * @brief MSVC RTTI introspection primitives for x64 MSVC binaries. | ||
| 17 | * @details Works on raw addresses across DLL boundaries. An unreadable page, an absent or malformed COL, or a zero | ||
| 18 | * RVA returns a failure. A guarded read whose guard-page re-arm fails (`[B-20]`) passes its fault to the | ||
| 19 | * host. Names are mangled, for example ".?AVMyClass@ns@@", and compare byte-exact. | ||
| 20 | * | ||
| 21 | * A type built under /GR- has no RTTI record, so every resolver fails closed for it. The resolvers still | ||
| 22 | * read the records that linked /GR code, such as the static CRT, emits into a /GR- host. The raw-byte | ||
| 23 | * fallbacks are @ref scan::find_string_xref and @ref scan::read_code_constant. See | ||
| 24 | * docs/guides/rtti/rtti-walker.md. | ||
| 25 | * @warning `[B-100]` Under the loader lock, call @ref TypeIdentity::matches only after it is warm, or use another | ||
| 26 | * Callback-safe entry point. Cold identity paths and setup routes can query the loader or sweep an image. | ||
| 27 | */ | ||
| 28 | namespace rtti | ||
| 29 | { | ||
| 30 | /// Default cap on the mangled-name length read into a heap-allocated string. | ||
| 31 | inline constexpr std::size_t DEFAULT_TYPE_NAME_MAX = 256; | ||
| 32 | |||
| 33 | /// Hard upper bound on any single mangled-name read. | ||
| 34 | inline constexpr std::size_t MAX_TYPE_NAME_LEN = 1024; | ||
| 35 | |||
| 36 | /** @brief Completeness of a reverse-RTTI sweep. Only @ref Complete authorizes a unique or absent verdict. */ | ||
| 37 | enum class Traversal : std::uint8_t | ||
| 38 | { | ||
| 39 | /// Every eligible section was enumerated, and every page in it was read. | ||
| 40 | Complete = 0, | ||
| 41 | /** @brief The sweep under-covered the image. */ | ||
| 42 | Incomplete = 1, | ||
| 43 | /** @brief The internal fixed buffer filled, so unseen sections or matches can exist. */ | ||
| 44 | Saturated = 2 | ||
| 45 | }; | ||
| 46 | |||
| 47 | /** @brief Outcome of a checked mangled-name read (@ref type_name_checked). */ | ||
| 48 | enum class NameStatus : std::uint8_t | ||
| 49 | { | ||
| 50 | /// The full NUL-terminated name was copied. | ||
| 51 | Ok = 0, | ||
| 52 | /** @brief The NUL-terminated copy is a proper prefix and must not be compared for identity. */ | ||
| 53 | Truncated = 1, | ||
| 54 | /// No name was read (null or low vtable, absent or malformed COL, unreadable page). | ||
| 55 | Failed = 2 | ||
| 56 | }; | ||
| 57 | |||
| 58 | /** @brief Result of @ref type_name_checked. */ | ||
| 59 | struct NameRead | ||
| 60 | { | ||
| 61 | /// Name bytes written, without the NUL terminator. | ||
| 62 | std::size_t written = 0; | ||
| 63 | /// Whether the copy is complete, a truncated prefix, or a failure. | ||
| 64 | NameStatus status = NameStatus::Failed; | ||
| 65 | }; | ||
| 66 | |||
| 67 | /** @brief Result of @ref vtables_for_type_checked. */ | ||
| 68 | struct VtablesResult | ||
| 69 | { | ||
| 70 | /// Distinct sub-object vtables that match (the same value @ref vtables_for_type returns). | ||
| 71 | std::size_t count = 0; | ||
| 72 | /** @brief Sweep completeness. Under Incomplete or Saturated, @ref count is only a floor. */ | ||
| 73 | Traversal completeness = Traversal::Complete; | ||
| 74 | }; | ||
| 75 | |||
| 76 | /** @brief Answer of @ref region_rtti_presence, with an absence distinct from an incomplete sweep. */ | ||
| 77 | enum class RttiPresence : std::uint8_t | ||
| 78 | { | ||
| 79 | /// At least one resolvable RTTI record was found. A hit is sound regardless of completeness. | ||
| 80 | Present = 0, | ||
| 81 | /// The sweep completed and found no record: an authoritative absence (an MSVC /GR- scope, a data module). | ||
| 82 | Absent = 1, | ||
| 83 | /** @brief The sweep did not complete, so absence cannot be concluded. */ | ||
| 84 | Incomplete = 2 | ||
| 85 | }; | ||
| 86 | |||
| 87 | /** | ||
| 88 | * @brief Reads the MSVC RTTI mangled type-descriptor name for the object whose runtime vtable is at @p vtable. | ||
| 89 | * @param max_len Maximum name length to copy, clamped to @ref MAX_TYPE_NAME_LEN. Zero means | ||
| 90 | * @ref DEFAULT_TYPE_NAME_MAX. A longer name comes back as a truncated prefix. If truncation | ||
| 91 | * matters, use @ref type_name_checked. | ||
| 92 | * @return The mangled name, or std::nullopt on any read or allocation failure or for an empty name. | ||
| 93 | * @note Setup/control-plane only: the call makes one heap allocation and queries the loader. | ||
| 94 | */ | ||
| 95 | [[nodiscard]] std::optional<std::string> | ||
| 96 | type_name_of(Address vtable, std::size_t max_len = DEFAULT_TYPE_NAME_MAX) noexcept; | ||
| 97 | |||
| 98 | /** | ||
| 99 | * @brief Zero-allocation form of @ref type_name_of. | ||
| 100 | * @param out Destination buffer, NUL-terminated when @p out_len > 0. On failure its first byte is '\0'. Must | ||
| 101 | * be non-null when @p out_len > 0. | ||
| 102 | * @param out_len Capacity of @p out, NUL terminator included. The call writes at most @p out_len bytes. | ||
| 103 | * @return Name bytes written without the NUL terminator, or 0 on failure or empty output. | ||
| 104 | * @note Setup/control-plane only: each call runs the COL prelude, which queries the loader. Cache a | ||
| 105 | * @ref TypeIdentity for a per-frame check. | ||
| 106 | */ | ||
| 107 | [[nodiscard]] std::size_t type_name_into(Address vtable, char *out, std::size_t out_len) noexcept; | ||
| 108 | |||
| 109 | /** | ||
| 110 | * @brief Truncation-reporting form of @ref type_name_into, with the same buffer contract. | ||
| 111 | * @return The name bytes written and the status. @ref NameStatus::Truncated means that @p out holds only a | ||
| 112 | * prefix. The name did not fit @p out or the @ref MAX_TYPE_NAME_LEN cap, or it has no readable | ||
| 113 | * terminator inside its module. @ref NameStatus::Failed leaves @p out empty. | ||
| 114 | * @note Setup/control-plane only (see @ref type_name_into). | ||
| 115 | */ | ||
| 116 | [[nodiscard]] NameRead type_name_checked(Address vtable, char *out, std::size_t out_len) noexcept; | ||
| 117 | |||
| 118 | /** | ||
| 119 | * @brief A stable, mapping-scoped identity token for the module currently mapped over @p addr. | ||
| 120 | * @details Folds the image base plus the three @ref scan::image_identity fields (SizeOfImage, PE TimeDateStamp, | ||
| 121 | * and the section table) into 64 bits. The token is stable while one image stays mapped. It changes | ||
| 122 | * when a same-base replacement changes an identity-bearing PE header field. | ||
| 123 | * @return A nonzero token, or 0 when @p addr is not inside a loaded module or the module's PE headers do not | ||
| 124 | * parse. | ||
| 125 | * @note Setup/control-plane only: it queries the loader for the module at @p addr, then reads its PE header. | ||
| 126 | * @warning This is layout identity, not content identity. A replacement that preserves every folded header | ||
| 127 | * field and changes only section bytes stays invisible. | ||
| 128 | */ | ||
| 129 | [[nodiscard]] std::uint64_t image_generation(Address addr) noexcept; | ||
| 130 | |||
| 131 | /** | ||
| 132 | * @brief Tests whether the MSVC RTTI mangled name for @p vtable equals @p expected exactly. | ||
| 133 | * @details Compares the name and its NUL byte for byte, so a proper prefix or a substring does not match. | ||
| 134 | * @param expected Must be non-empty and shorter than @ref MAX_TYPE_NAME_LEN. | ||
| 135 | * @return true on an exact match. false on a mismatch, a read failure, or an empty or oversized @p expected. | ||
| 136 | * @note Setup/control-plane only: each call runs the COL prelude, which queries the loader. It does not | ||
| 137 | * allocate. @ref TypeIdentity::matches is the per-frame route. | ||
| 138 | */ | ||
| 139 | [[nodiscard]] bool vtable_is_type(Address vtable, std::string_view expected) noexcept; | ||
| 140 | |||
| 141 | /** | ||
| 142 | * @brief Generation-bearing cache for repeated @ref find_in_pointer_table calls with one expected type. | ||
| 143 | * @details Concurrent lookups are supported, and publication does not block. | ||
| 144 | */ | ||
| 145 | class PointerTableCache | ||
| 146 | { | ||
| 147 | public: | ||
| 148 | /// Constructs an empty cache. | ||
| 149 | PointerTableCache() noexcept = default; | ||
| 150 | PointerTableCache(const PointerTableCache &) = delete; | ||
| 151 | PointerTableCache &operator=(const PointerTableCache &) = delete; | ||
| 152 | PointerTableCache(PointerTableCache &&) = delete; | ||
| 153 | PointerTableCache &operator=(PointerTableCache &&) = delete; | ||
| 154 | ~PointerTableCache() noexcept = default; | ||
| 155 | |||
| 156 | /** | ||
| 157 | * @brief Clears the cached snapshot so the next lookup starts cold. | ||
| 158 | * @note Setup/control-plane only: waits for an in-progress cache publication to finish. | ||
| 159 | */ | ||
| 160 | void reset() noexcept; | ||
| 161 | |||
| 162 | private: | ||
| 163 | friend std::optional<Address> find_in_pointer_table( | ||
| 164 | Address table, | ||
| 165 | std::size_t slot_count, | ||
| 166 | std::string_view expected, | ||
| 167 | PointerTableCache &cache, | ||
| 168 | std::size_t stride | ||
| 169 | ) noexcept; | ||
| 170 | |||
| 171 | // Single-writer sequence that keeps the {vtable, image base, generation} snapshot coherent. | ||
| 172 | std::atomic_flag m_writer{}; | ||
| 173 | std::atomic<std::uint32_t> m_seq{0}; | ||
| 174 | std::atomic<Address> m_vtable{Address{}}; | ||
| 175 | std::atomic<Address> m_image_base{Address{}}; | ||
| 176 | std::atomic<std::uint64_t> m_generation{0}; | ||
| 177 | // Advanced by reset() so a lookup that started earlier cannot publish across the reset boundary. | ||
| 178 | std::atomic<std::uint64_t> m_epoch{0}; | ||
| 179 | }; | ||
| 180 | |||
| 181 | /** | ||
| 182 | * @brief Scans a pointer table for the first slot whose object has the given RTTI type-descriptor name. | ||
| 183 | * @details Treats @p table as @p slot_count slots spaced @p stride bytes apart. A cold or null cache walks RTTI | ||
| 184 | * per slot through @ref vtable_is_type. A warm cache compares each slot against the cached vtable. If | ||
| 185 | * no slot carries it, the call clears the stale value and runs one cold pass. A cold-path match | ||
| 186 | * refreshes the cache. A null Address means cold. | ||
| 187 | * @param vtable_cache Optional caller-owned cache, one per expected name. Pass nullptr to walk RTTI every call. | ||
| 188 | * @return The value stored in the first slot that matches, or std::nullopt. | ||
| 189 | * @warning The warm path assumes one vtable per expected name and skips a match with any other vtable. A | ||
| 190 | * secondary-base sub-object vtable, or a vtable of the same class in another module, shares that name. | ||
| 191 | * @warning The raw atomic carries no image generation. Clear it at module-lifecycle boundaries, or use the | ||
| 192 | * @ref PointerTableCache overload. | ||
| 193 | * @note Callback-safe on the warm-cache path, which costs two guarded reads and one compare per slot. A cold or | ||
| 194 | * stale cache is setup/control-plane work. | ||
| 195 | */ | ||
| 196 | [[nodiscard]] std::optional<Address> find_in_pointer_table( | ||
| 197 | Address table, | ||
| 198 | std::size_t slot_count, | ||
| 199 | std::string_view expected, | ||
| 200 | std::atomic<Address> *vtable_cache = nullptr, | ||
| 201 | std::size_t stride = sizeof(std::uintptr_t) | ||
| 202 | ) noexcept; | ||
| 203 | |||
| 204 | /** | ||
| 205 | * @brief Generation-checked overload of @ref find_in_pointer_table. | ||
| 206 | * @details A warm call reads the image generation before and after the slot scan and accepts the snapshot only | ||
| 207 | * while it stays current. A stale snapshot is cleared and resolved cold. | ||
| 208 | * @param cache Caller-owned cache, dedicated to one expected name. If the cache outlives a module unload or | ||
| 209 | * reload, use this overload. | ||
| 210 | * @note Callback-safe on the warm-cache path. A cold or stale cache is setup/control-plane work. | ||
| 211 | */ | ||
| 212 | [[nodiscard]] std::optional<Address> find_in_pointer_table( | ||
| 213 | Address table, | ||
| 214 | std::size_t slot_count, | ||
| 215 | std::string_view expected, | ||
| 216 | PointerTableCache &cache, | ||
| 217 | std::size_t stride = sizeof(std::uintptr_t) | ||
| 218 | ) noexcept; | ||
| 219 | |||
| 220 | /** | ||
| 221 | * @brief Resolves the primary (most-derived) vtable for a class by its MSVC mangled name in one module image. | ||
| 222 | * @details Sweeps the readable, non-executable sections for the COL with name @p mangled and COL.offset 0. For | ||
| 223 | * a class used only as a secondary or virtual base, use @ref vtables_for_type. | ||
| 224 | * @return The primary vtable on a unique match. std::nullopt on absence, an invalid scope, ambiguous primaries, | ||
| 225 | * or an incomplete sweep. @ref vtables_for_type_checked tells absence from an incomplete sweep. | ||
| 226 | * @note Setup/control-plane only: run it once at init or behind a cached @ref TypeIdentity, never per frame. | ||
| 227 | */ | ||
| 228 | [[nodiscard]] std::optional<Address> | ||
| 229 | vtable_for_type(std::string_view mangled, Region range = Region::host()) noexcept; | ||
| 230 | |||
| 231 | /** | ||
| 232 | * @brief Collects every sub-object vtable that shares a class's mangled name. | ||
| 233 | * @param out Receives the vtables in ascending COL.offset order, primary first. It can be nullptr only when | ||
| 234 | * @p out_cap is 0 (a count-only query). | ||
| 235 | * @return Number of distinct matches, capped at an internal bound. A value above @p out_cap means @p out was | ||
| 236 | * truncated. After an incomplete or saturated sweep, the count is only a floor. | ||
| 237 | * @ref vtables_for_type_checked reports the completeness. | ||
| 238 | * @note Setup/control-plane only (see @ref vtable_for_type). | ||
| 239 | */ | ||
| 240 | [[nodiscard]] std::size_t vtables_for_type( | ||
| 241 | std::string_view mangled, | ||
| 242 | Address *out, | ||
| 243 | std::size_t out_cap, | ||
| 244 | Region range = Region::host() | ||
| 245 | ) noexcept; | ||
| 246 | |||
| 247 | /** | ||
| 248 | * @brief Completeness-reporting form of @ref vtables_for_type, with the same parameters and count. | ||
| 249 | * @note Setup/control-plane only (see @ref vtable_for_type). | ||
| 250 | */ | ||
| 251 | [[nodiscard]] VtablesResult vtables_for_type_checked( | ||
| 252 | std::string_view mangled, | ||
| 253 | Address *out, | ||
| 254 | std::size_t out_cap, | ||
| 255 | Region range = Region::host() | ||
| 256 | ) noexcept; | ||
| 257 | |||
| 258 | /** | ||
| 259 | * @brief Reports whether a module region currently contains any resolvable MSVC RTTI record. | ||
| 260 | * @details A true answer proves only that some record exists, not that the caller's type resolves. A false | ||
| 261 | * answer means only that the swept part held no record. See @ref region_rtti_presence. | ||
| 262 | * @return false also when @p range is not a valid mapped image. | ||
| 263 | * @note Setup/control-plane only (see @ref vtable_for_type). It has no re-sweep throttle, so a records-free | ||
| 264 | * scope pays a full sweep on every call. | ||
| 265 | * @note An absent verdict on a still-packed image describes only the current mapping, not a /GR- build. After | ||
| 266 | * the image unpacks, inspect again. Do not cache the result as permanent. | ||
| 267 | */ | ||
| 268 | [[nodiscard]] bool region_has_rtti(Region range = Region::host()) noexcept; | ||
| 269 | |||
| 270 | /** | ||
| 271 | * @brief Completeness-reporting form of @ref region_has_rtti. An invalid @p range reports Incomplete. | ||
| 272 | * @note Setup/control-plane only (see @ref vtable_for_type). | ||
| 273 | */ | ||
| 274 | [[nodiscard]] RttiPresence region_rtti_presence(Region range = Region::host()) noexcept; | ||
| 275 | |||
| 276 | /** | ||
| 277 | * @brief Cached, self-healing, generation-aware identity handle for a class vtable. | ||
| 278 | * @details Resolves the primary vtable lazily through @ref vtable_for_type and caches it with its image | ||
| 279 | * generation. The warm path checks that generation on every call and resolves again after a remap. A | ||
| 280 | * private-buffer scope has no module generation and must be reset explicitly through @ref invalidate. | ||
| 281 | * @note Take identity from the cached vtable address, never from the vtable's slot contents. Under /OPT:ICF | ||
| 282 | * folding, two distinct classes can share function-pointer slots. | ||
| 283 | * @note Owns a copy of its mangled name. Hold it as a static or a long-lived member. | ||
| 284 | */ | ||
| 285 | class TypeIdentity | ||
| 286 | { | ||
| 287 | public: | ||
| 288 | /** | ||
| 289 | * @brief Constructs a cached identity for @p mangled, scoped to @p range. | ||
| 290 | * @throws std::bad_alloc if the name copy cannot be allocated. | ||
| 291 | * @note Setup/control-plane only: cache construction allocates. | ||
| 292 | */ | ||
| 293 | explicit TypeIdentity(std::string_view mangled, Region range = Region::host()); | ||
| 294 | |||
| 295 | TypeIdentity(const TypeIdentity &) = delete; | ||
| 296 | TypeIdentity &operator=(const TypeIdentity &) = delete; | ||
| 297 | TypeIdentity(TypeIdentity &&) = delete; | ||
| 298 | TypeIdentity &operator=(TypeIdentity &&) = delete; | ||
| 299 | 14 | ~TypeIdentity() noexcept = default; | |
| 300 | |||
| 301 | /** | ||
| 302 | * @brief Tests whether @p vtable is this type's primary vtable. | ||
| 303 | * @details Resolves on first call, then compares. A type that cannot be resolved never matches. | ||
| 304 | * @note Callback-safe once warm: the generation check performs bounded guarded PE-header reads. A changed | ||
| 305 | * image triggers a setup-cost resolve. | ||
| 306 | */ | ||
| 307 | [[nodiscard]] bool matches(Address vtable) const noexcept; | ||
| 308 | |||
| 309 | /** | ||
| 310 | * @brief Returns the primary vtable, or std::nullopt when this call does not resolve it in the configured | ||
| 311 | * range. | ||
| 312 | * @note Callback-safe once warm: the first call runs a setup-cost module sweep and caches a success. A miss | ||
| 313 | * is not cached, and an internal cooldown throttles the retry sweep. A call within that cooldown, or | ||
| 314 | * one that overlaps another resolve, can return std::nullopt. | ||
| 315 | */ | ||
| 316 | [[nodiscard]] std::optional<Address> vtable() const noexcept; | ||
| 317 | |||
| 318 | /** | ||
| 319 | * @brief Drops the cached resolve so the next @ref vtable or @ref matches resolves again from scratch. | ||
| 320 | * @details Idempotent and safe to call at any time, for example after its module was reloaded. | ||
| 321 | * @note Setup/control-plane only: waits for an in-progress cache publication to finish. | ||
| 322 | */ | ||
| 323 | void invalidate() noexcept; | ||
| 324 | |||
| 325 | private: | ||
| 326 | std::string m_mangled; | ||
| 327 | Region m_range; | ||
| 328 | bool m_tracks_module_range{false}; | ||
| 329 | |||
| 330 | // A successful resolve stores the vtable, and invalidate() or a generation change clears it. A release | ||
| 331 | // store of m_resolved publishes m_cached. | ||
| 332 | mutable std::atomic<Address> m_cached{Address{}}; | ||
| 333 | mutable std::atomic<bool> m_resolved{false}; | ||
| 334 | |||
| 335 | // image_generation of the resolved type's module at the last success (0 = none, or a non-module range). | ||
| 336 | mutable std::atomic<std::uint64_t> m_image_stamp{0}; | ||
| 337 | mutable std::atomic<Address> m_image_base{Address{}}; | ||
| 338 | |||
| 339 | // Serializes the short publish/clear transaction. The RTTI sweep runs without it. | ||
| 340 | mutable std::atomic_flag m_cache_writer{}; | ||
| 341 | // Incremented on every clear, so a resolve already in flight cannot publish afterward. | ||
| 342 | mutable std::atomic<std::uint64_t> m_cache_epoch{0}; | ||
| 343 | |||
| 344 | // Last resolve attempt that controls a retry, in milliseconds (0 = never). A warm hit does not write it. | ||
| 345 | mutable std::atomic<std::uint64_t> m_last_attempt_ms{0}; | ||
| 346 | }; | ||
| 347 | } // namespace rtti | ||
| 348 | } // namespace DetourModKit | ||
| 349 | |||
| 350 | #endif // DETOURMODKIT_RTTI_HPP | ||
| 351 |