GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 100.0% 3 / 0 / 3
Functions: 100.0% 1 / 0 / 1
Branches: 50.0% 1 / 0 / 2

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