include/DetourModKit/rtti.hpp
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | #ifndef DETOURMODKIT_RTTI_HPP | ||
| 2 | #define DETOURMODKIT_RTTI_HPP | ||
| 3 | |||
| 4 | #include "DetourModKit/memory.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 C++ ABI structures laid down by the Visual | ||
| 19 | * Studio toolchain to recover the mangled type-descriptor name for a runtime object pointed to by a | ||
| 20 | * vtable. The implementation operates on raw addresses and never invokes typeid() or dynamic_cast, so it | ||
| 21 | * works across DLL boundaries and against third-party MSVC binaries (game engines, middleware) without | ||
| 22 | * symbol cooperation. | ||
| 23 | * | ||
| 24 | * All entry points are noexcept and SEH-guarded; an unreadable page, missing COL, or zero RVA produces a | ||
| 25 | * failure return rather than a fault. Names are returned in the MSVC mangled form (e.g. | ||
| 26 | * ".?AVMyClass@ns@@") so callers can perform an exact byte-equal comparison instead of resolving through | ||
| 27 | * UnDecorateSymbolName. | ||
| 28 | * | ||
| 29 | * The ABI layout this module relies on (COL at vtable - 8, TypeDescriptor RVA at COL + 0x0C, mangled name | ||
| 30 | * at TD + 0x10, COL.pSelf RVA at COL + 0x14 on x64) has been stable across every release of MSVC since | ||
| 31 | * Visual C++ 2010. | ||
| 32 | * | ||
| 33 | * RTTI-disabled host binaries: every resolver in this namespace -- @ref type_name_of, | ||
| 34 | * @ref type_name_into, @ref vtable_is_type, @ref find_in_pointer_table, the reverse-direction | ||
| 35 | * @ref vtable_for_type / @ref vtables_for_type / @ref TypeIdentity::matches, and the self-heal backends | ||
| 36 | * in @ref rtti_dissect.hpp (which include @ref identify_pointee_type, @ref reverse_scan_block, | ||
| 37 | * @ref heal_landmark, @ref heal_offset, and @ref solve_fingerprint) -- is built on the | ||
| 38 | * COL/TypeDescriptor layout above. When the host binary is compiled with RTTI disabled (`/GR-` for MSVC | ||
| 39 | * and clang-cl), the TypeDescriptor records DMK needs to read are not emitted, and every RTTI-based | ||
| 40 | * resolver returns its fail-closed sentinel (`std::nullopt`, `std::unexpected`, `false`, or a zero | ||
| 41 | * count) rather than a fault or a wrong answer. The forward walker is still safe to call; it just | ||
| 42 | * cannot identify types. | ||
| 43 | * | ||
| 44 | * The primary fallback for an RTTI-off consumer is @ref Scanner::find_string_xref or | ||
| 45 | * @ref Scanner::read_code_constant, which operate on raw bytes and do not require RTTI records. The | ||
| 46 | * long-form failure-mode discussion for each function is in docs/misc/rtti-walker.md and | ||
| 47 | * docs/misc/rtti-self-heal.md. | ||
| 48 | */ | ||
| 49 | namespace Rtti | ||
| 50 | { | ||
| 51 | /// Default cap on the mangled-name length read into a heap-allocated string. | ||
| 52 | inline constexpr std::size_t DEFAULT_TYPE_NAME_MAX = 256; | ||
| 53 | |||
| 54 | /// Hard upper bound on any single mangled-name read. | ||
| 55 | inline constexpr std::size_t MAX_TYPE_NAME_LEN = 1024; | ||
| 56 | |||
| 57 | /** | ||
| 58 | * @brief Reads the MSVC RTTI mangled type-descriptor name for the object whose runtime vtable is at @p vtable. | ||
| 59 | * @details Walks @c vtable - 8 (RTTICompleteObjectLocator pointer), then @c col + 0x0C (TypeDescriptor RVA, | ||
| 60 | * base-relative), then @c td + 0x10 (zero-terminated mangled name such as ".?AVMyClass@ns@@"). The | ||
| 61 | * owning module's image base comes from the loader-reported module range; on x64 the @c col.pSelf RVA | ||
| 62 | * at @c col + 0x14 (signature == 1) must reconstruct that same base (@c col_addr - @c pSelf), which | ||
| 63 | * cross-checks the COL against a forged or relocated structure. Any signature other than the x64 value | ||
| 64 | * is rejected. | ||
| 65 | * | ||
| 66 | * Reads up to @p max_len bytes from the name buffer in page-bounded chunks via @ref | ||
| 67 | * Memory::seh_read_bytes; the first NUL byte terminates the result. All reads are | ||
| 68 | * SEH-guarded on MSVC and VirtualQuery-guarded on MinGW. | ||
| 69 | * @param vtable Runtime vtable pointer (the first qword of the object). | ||
| 70 | * @param max_len Maximum mangled-name length to copy; clamped to @ref MAX_TYPE_NAME_LEN. Zero is replaced with | ||
| 71 | * @ref DEFAULT_TYPE_NAME_MAX. | ||
| 72 | * @return The mangled name on success, std::nullopt on any failure (null vtable, unmapped page, missing COL, | ||
| 73 | * bad RVA, allocation failure). | ||
| 74 | * @note Performs one heap allocation for the returned std::string. For per-frame identity probes use @ref | ||
| 75 | * vtable_is_type or @ref type_name_into to avoid the allocation. | ||
| 76 | */ | ||
| 77 | [[nodiscard]] std::optional<std::string> type_name_of(std::uintptr_t vtable, | ||
| 78 | std::size_t max_len = DEFAULT_TYPE_NAME_MAX) noexcept; | ||
| 79 | |||
| 80 | /** | ||
| 81 | * @brief Zero-allocation form of @ref type_name_of. | ||
| 82 | * @details Writes the mangled name into @p out (always NUL-terminated when @p out_len > 0) and returns the | ||
| 83 | * number of bytes written excluding the terminator. On any failure the output buffer's first byte is | ||
| 84 | * set to '\0' and 0 is returned. | ||
| 85 | * @param vtable Runtime vtable pointer (the first qword of the object). | ||
| 86 | * @param out Destination buffer. Must be non-null when @p out_len > 0. | ||
| 87 | * @param out_len Capacity of @p out including the NUL terminator. The function never writes more than @p | ||
| 88 | * out_len bytes. | ||
| 89 | * @return Number of name bytes written (excluding the NUL terminator), or 0 on failure or empty output. | ||
| 90 | */ | ||
| 91 | [[nodiscard]] std::size_t type_name_into(std::uintptr_t vtable, char *out, std::size_t out_len) noexcept; | ||
| 92 | |||
| 93 | /** | ||
| 94 | * @brief Tests whether the MSVC RTTI mangled name for @p vtable equals @p expected exactly. | ||
| 95 | * @details Performs a byte-exact comparison of the mangled name plus the terminating NUL, rejecting both proper | ||
| 96 | * prefix and substring matches. The read is bounded by the length of @p expected plus one byte, so no | ||
| 97 | * allocation occurs and the per-call cost is dominated by the SEH-guarded read of @p expected.size() + | ||
| 98 | * 1 bytes from the name buffer. | ||
| 99 | * @param vtable Runtime vtable pointer. | ||
| 100 | * @param expected Mangled name to compare against. Must be non-empty and shorter than @ref MAX_TYPE_NAME_LEN. | ||
| 101 | * @return true on exact match; false on mismatch, on any read failure, or when @p expected is empty or | ||
| 102 | * oversized. | ||
| 103 | */ | ||
| 104 | [[nodiscard]] bool vtable_is_type(std::uintptr_t vtable, std::string_view expected) noexcept; | ||
| 105 | |||
| 106 | /** | ||
| 107 | * @brief Scans a pointer-table for the first slot whose object has the given RTTI type-descriptor name. | ||
| 108 | * @details Treats @p table as an array of @p slot_count entries each @p stride bytes wide. For every non-null | ||
| 109 | * slot the function dereferences the object pointer, reads the object's first qword as a vtable, and | ||
| 110 | * either: | ||
| 111 | * - on a cold cache (or when @p vtable_cache is nullptr) | ||
| 112 | * calls @ref vtable_is_type to perform the full RTTI walk; | ||
| 113 | * - on a warm cache (a previously-resolved vtable address) | ||
| 114 | * performs a single qword compare and skips slots whose vtable differs. | ||
| 115 | * | ||
| 116 | * The first matching slot is returned. When a match is found on the cold path and @p vtable_cache is | ||
| 117 | * non-null, the matching vtable is stored with memory_order_relaxed so subsequent calls can take the | ||
| 118 | * warm path. Cache writes use a single relaxed store because concurrent first-callers converge on the | ||
| 119 | * same vtable value (image-resident vtables are unique per concrete type). | ||
| 120 | * | ||
| 121 | * Caller-owned cache shape: one std::atomic<std::uintptr_t> per expected name, default-initialised to | ||
| 122 | * zero. Zero encodes "cold". | ||
| 123 | * @param table Base address of the pointer table. | ||
| 124 | * @param slot_count Number of slots to scan. | ||
| 125 | * @param expected Mangled name to match. | ||
| 126 | * @param vtable_cache Optional caller-owned cache slot. Pass nullptr to skip caching (every call walks RTTI). | ||
| 127 | * @param stride Byte distance between adjacent slot addresses. Defaults to sizeof(std::uintptr_t) for a packed | ||
| 128 | * pointer array; pass a larger stride for tables that interleave per-slot metadata between | ||
| 129 | * pointers. | ||
| 130 | * @return The object pointer (the value stored in the slot) on first match, or std::nullopt if no slot matched. | ||
| 131 | * @warning The warm-cache path assumes one canonical vtable address per expected name. If multiple derived | ||
| 132 | * concrete classes share the same base-mangled name and the table holds a mix of them, only slots | ||
| 133 | * whose vtable equals the | ||
| 134 | * first-resolved instance are returned on the warm path; | ||
| 135 | * other matches are skipped. For MSVC RTTI this is correct because mangled names encode the | ||
| 136 | * most-derived class, not the base. | ||
| 137 | */ | ||
| 138 | [[nodiscard]] std::optional<std::uintptr_t> | ||
| 139 | find_in_pointer_table(std::uintptr_t table, std::size_t slot_count, std::string_view expected, | ||
| 140 | std::atomic<std::uintptr_t> *vtable_cache = nullptr, | ||
| 141 | std::size_t stride = sizeof(std::uintptr_t)) noexcept; | ||
| 142 | |||
| 143 | /** | ||
| 144 | * @brief Resolves the primary (most-derived) vtable for a class by its | ||
| 145 | * MSVC mangled name, scoped to one module image. | ||
| 146 | * @details The reverse of @ref vtable_is_type: instead of "what type is this vtable", it answers "where is the | ||
| 147 | * vtable for this type". The module's readable, non-executable sections are swept for an | ||
| 148 | * RTTICompleteObjectLocator whose TypeDescriptor name equals @p mangled and whose COL.offset is 0, and | ||
| 149 | * the vtable that points back to that COL (via its vtable[-1] meta-slot) is returned. Every candidate | ||
| 150 | * is validated through the same COL prelude the forward walker uses (x64 signature, the | ||
| 151 | * pSelf-vs-loader-base cross-check, in-module bounds), so a forged or coincidental match is rejected | ||
| 152 | * rather than returned. | ||
| 153 | * | ||
| 154 | * COL.offset == 0 is required so the result is the vtable an object pointer's first qword holds for a | ||
| 155 | * most-derived instance, which is exactly what an identity check compares against. A class used only | ||
| 156 | * as a secondary or virtual base has its first qword pointing at a COL.offset != 0 sub-object vtable; | ||
| 157 | * use @ref vtables_for_type for that case. | ||
| 158 | * @param mangled Exact MSVC mangled name (e.g. ".?AVMyClass@ns@@"). | ||
| 159 | * @param range Module image to search. Defaults to the host EXE. The scope is load-bearing for correctness, not | ||
| 160 | * merely | ||
| 161 | * ergonomic: the same mangled name can appear in several | ||
| 162 | * loaded modules and COL RVAs are image-base-relative. | ||
| 163 | * @return The primary vtable address on a unique match; std::nullopt when no COL.offset == 0 match exists, when | ||
| 164 | * @p range is invalid, or when more than one distinct primary vtable shares the name (an ambiguous | ||
| 165 | * image: the resolver fails closed rather than guessing). | ||
| 166 | */ | ||
| 167 | [[nodiscard]] std::optional<std::uintptr_t> | ||
| 168 | vtable_for_type(std::string_view mangled, Memory::ModuleRange range = Memory::host_module_range()) noexcept; | ||
| 169 | |||
| 170 | /** | ||
| 171 | * @brief Collects every sub-object vtable sharing a class's mangled name. | ||
| 172 | * @details Multiple or virtual inheritance gives one class (one | ||
| 173 | * TypeDescriptor, one mangled name) several COLs -- one per base sub-object, each at a distinct | ||
| 174 | * COL.offset and each referenced by its own vtable. This returns all of them so a caller matching an | ||
| 175 | * object pointer that may point at a secondary base is not limited to the primary vtable. Each match | ||
| 176 | * is validated through the COL prelude exactly as @ref vtable_for_type. | ||
| 177 | * @param mangled Exact MSVC mangled name. | ||
| 178 | * @param out Destination buffer for the matching vtable addresses, written in ascending COL.offset order (the | ||
| 179 | * primary, offset 0, first). May be nullptr only when @p out_cap is 0 (count-only query). | ||
| 180 | * @param out_cap Capacity of @p out; at most @p out_cap addresses are written even when more matches exist. | ||
| 181 | * @param range Module image to search. Defaults to the host EXE. | ||
| 182 | * @return Total number of distinct matching vtables found (capped at an internal upper bound that far exceeds | ||
| 183 | * any real inheritance graph). A return value greater than @p out_cap signals the output was truncated. | ||
| 184 | */ | ||
| 185 | [[nodiscard]] std::size_t vtables_for_type(std::string_view mangled, std::uintptr_t *out, std::size_t out_cap, | ||
| 186 | Memory::ModuleRange range = Memory::host_module_range()) noexcept; | ||
| 187 | |||
| 188 | /** | ||
| 189 | * @brief Cached, self-healing identity handle for a class vtable. | ||
| 190 | * @details Resolves the primary vtable for a mangled name once (lazily, on first use) via @ref vtable_for_type | ||
| 191 | * and caches it, so a per-frame identity check is a single qword compare with no RTTI walk -- the same | ||
| 192 | * warm-cache shape as @ref find_in_pointer_table. Because the cached value is keyed on the stable | ||
| 193 | * class name, it survives a game patch that relocates the vtable (the name does not move), which a | ||
| 194 | * hard-coded vtable literal does not. | ||
| 195 | * @note Take identity from the cached vtable ADDRESS (the vtable[-1] | ||
| 196 | * COL-anchored value), never from the vtable's slot contents: under the MSVC linker's identical-COMDAT | ||
| 197 | * folding (/OPT:ICF) two distinct classes can share folded function-pointer slots, so a slot-content | ||
| 198 | * comparison is not class-unique. | ||
| 199 | * @note Holds the name as a non-owning view; the backing string must outlive the handle. Non-copyable and | ||
| 200 | * non-movable (it owns atomic cache state); hold it as a static or a long-lived member. | ||
| 201 | */ | ||
| 202 | class TypeIdentity | ||
| 203 | { | ||
| 204 | public: | ||
| 205 | /** | ||
| 206 | * @brief Constructs an identity for @p mangled, scoped to @p range. | ||
| 207 | * @param mangled Exact MSVC mangled name. Stored as a view; the backing storage must outlive the handle. | ||
| 208 | * @param range Module image to resolve in. Defaults to the host EXE. | ||
| 209 | */ | ||
| 210 | explicit TypeIdentity(std::string_view mangled, | ||
| 211 | Memory::ModuleRange range = Memory::host_module_range()) noexcept; | ||
| 212 | |||
| 213 | /** | ||
| 214 | * @brief Constructs a cached identity from a null-terminated mangled type name. | ||
| 215 | * @details This exact-match overload keeps string-literal call sites unambiguous while the deleted | ||
| 216 | * std::string rvalue overload rejects dangling temporaries. A null pointer is treated as an empty | ||
| 217 | * name and resolves to no match. | ||
| 218 | * @param mangled Null-terminated MSVC RTTI name. The backing bytes must outlive this identity. | ||
| 219 | * @param range Module range searched for the primary vtable. | ||
| 220 | */ | ||
| 221 | 3 | explicit TypeIdentity(const char *mangled, Memory::ModuleRange range = Memory::host_module_range()) noexcept | |
| 222 |
1/2✓ Branch 2 → 3 taken 3 times.
✗ Branch 2 → 4 not taken.
|
3 | : TypeIdentity(mangled != nullptr ? std::string_view(mangled) : std::string_view{}, range) |
| 223 | { | ||
| 224 | 3 | } | |
| 225 | |||
| 226 | /** | ||
| 227 | * @brief Rejects std::string temporaries because the identity stores a non-owning view. | ||
| 228 | * @details A string literal, std::string_view, or long-lived std::string lvalue can still bind safely. A | ||
| 229 | * std::string rvalue would dangle as soon as the constructor returns, so it is a compile-time | ||
| 230 | * error. | ||
| 231 | */ | ||
| 232 | TypeIdentity(std::string &&mangled, Memory::ModuleRange range = Memory::host_module_range()) = delete; | ||
| 233 | |||
| 234 | /** | ||
| 235 | * @brief Tests whether @p vtable is this type's primary vtable. | ||
| 236 | * @details Resolves on first call, then compares. Returns false when the type cannot be resolved, so a | ||
| 237 | * missing type never matches. | ||
| 238 | * @param vtable Candidate vtable (an object's first qword). | ||
| 239 | * @return true when @p vtable equals the resolved primary vtable. | ||
| 240 | */ | ||
| 241 | [[nodiscard]] bool matches(std::uintptr_t vtable) const noexcept; | ||
| 242 | |||
| 243 | /** | ||
| 244 | * @brief Returns the resolved primary vtable, resolving on first use. | ||
| 245 | * @return The vtable address, or std::nullopt if it cannot be resolved in the configured module range. | ||
| 246 | */ | ||
| 247 | [[nodiscard]] std::optional<std::uintptr_t> vtable() const noexcept; | ||
| 248 | |||
| 249 | private: | ||
| 250 | std::string_view m_mangled; | ||
| 251 | Memory::ModuleRange m_range; | ||
| 252 | |||
| 253 | // m_cached holds the resolved primary vtable and is written only on a SUCCESSFUL (nonzero) resolve. | ||
| 254 | // m_resolved latches that success and is published with release after m_cached is stored, so an | ||
| 255 | // acquire-load that observes m_resolved == true also observes the cached value. A failed resolve latches | ||
| 256 | // neither flag, so a later call retries once the type becomes resolvable instead of caching the miss as | ||
| 257 | // permanent. | ||
| 258 | mutable std::atomic<std::uintptr_t> m_cached{0}; | ||
| 259 | mutable std::atomic<bool> m_resolved{false}; | ||
| 260 | }; | ||
| 261 | } // namespace Rtti | ||
| 262 | } // namespace DetourModKit | ||
| 263 | |||
| 264 | #endif // DETOURMODKIT_RTTI_HPP | ||
| 265 |