./
Coverage:
low: ≥ 0%
medium: ≥ 75.0%
high: ≥ 90.0%
Lines:
1 / 1
100.0%
Branches:
-
-%
Functions:
1 / 1
100.0%

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