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

include/DetourModKit/anchor.hpp
Line Branch Exec Source
1 #ifndef DETOURMODKIT_ANCHOR_HPP
2 #define DETOURMODKIT_ANCHOR_HPP
3
4 /**
5 * @file anchor.hpp
6 * @brief Declarative anchor registry: one table that resolves a mod's patch-fragile constants and reports drift.
7 * @warning `[B-100]` Under the loader lock, call only the Callback-safe trust and quality queries. Resolution can
8 * allocate, query loader state, scan memory, or create threads.
9 */
10
11 #include "DetourModKit/error.hpp"
12 #include "DetourModKit/region.hpp"
13 #include "DetourModKit/scan.hpp"
14
15 #include <array>
16 #include <cstddef>
17 #include <cstdint>
18 #include <span>
19 #include <string_view>
20
21 namespace DetourModKit
22 {
23 namespace anchor
24 {
25 /** @brief Which backend resolves an anchor. `docs/guides/scanning/anchors.md` ranks the kinds by resilience. */
26 enum class AnchorKind : std::uint8_t
27 {
28 /// A class vtable address keyed on its mangled name (@ref rtti::vtable_for_type).
29 VtableIdentity,
30 /// An absolute address from a Direct or RIP-relative candidate cascade (@ref scan::resolve).
31 RipGlobal,
32 /// An in-code immediate or `[reg + disp]` displacement (@ref scan::read_code_constant).
33 CodeOperand,
34 /// A reference to an immutable string literal (@ref scan::find_string_xref).
35 StringXref,
36 /// A pinned literal with no backend. It cannot self-heal, so @ref AnchorQuality counts it as at risk.
37 Manual,
38 /// A reserved prologue-dataflow kind with no resolver, which reports @ref AnchorStatus::Unsupported.
39 CallArgHome,
40 /// A value accepted only when at least N of M independent sub-anchors resolve and agree.
41 Quorum,
42 /// A named export read from its module's PE Export Address Table (@ref scan::resolve_export).
43 ExportName,
44 /// The default for an omitted kind, which reports @ref AnchorStatus::Failed instead of a trusted address 0.
45 Unset
46 };
47
48 /// The number of @ref AnchorKind enumerators, which sizes the per-kind deny-list in @ref ScanProfile.
49 inline constexpr std::size_t ANCHOR_KIND_COUNT = 9;
50 static_assert(
51 static_cast<std::size_t>(AnchorKind::Unset) + 1 == ANCHOR_KIND_COUNT,
52 "ANCHOR_KIND_COUNT must track the AnchorKind enumerator count."
53 );
54
55 /** @brief How a @ref AnchorKind::Quorum decides that two resolved member values agree. */
56 enum class QuorumMatch : std::uint8_t
57 {
58 /// Two member values agree only when identical.
59 ExactValue,
60 /// Two member values agree when their gap is at most @ref Anchor::quorum_tolerance.
61 WithinTolerance
62 };
63
64 /** @brief The resolution outcome of one anchor. */
65 enum class AnchorStatus : std::uint8_t
66 {
67 /// The initial state of an untouched slot, which a resolve never returns.
68 Unresolved,
69 /// The backend resolved a value, and every applicable validator and corroboration check passed.
70 Resolved,
71 /// A backend miss, validator rejection, denied backend, or failed quorum, with no invented value.
72 Failed,
73 /// The kind has no resolver (@ref AnchorKind::CallArgHome).
74 Unsupported,
75 /// A quorum's members were not all pairwise-independent evidence.
76 QuorumNotIndependent,
77 /** @brief Values that disagree each reached the quorum threshold. Member order never picks a winner. */
78 QuorumAmbiguous
79 };
80
81 /** @brief A post-resolve predicate. A false return fails the anchor (@ref AnchorStatus::Failed, value 0). */
82 using AnchorValidator = bool (*)(std::int64_t value, const void *context) noexcept;
83
84 /** @brief A registry entry. A kind-labeled field applies only to that kind. Views must outlive the resolve. */
85 struct Anchor
86 {
87 /// An identifier echoed into the @ref ResolvedAnchor.
88 std::string_view label;
89 /// Which backend resolves this anchor.
90 AnchorKind kind = AnchorKind::Unset;
91
92 /// VtableIdentity: the MSVC mangled type name, for example ".?AVGameAudioEffect@engine@@".
93 std::string_view mangled;
94
95 /// RipGlobal / CodeOperand: the candidate ladder that resolves to the address or the instruction site.
96 std::span<const scan::Candidate> site;
97 /// CodeOperand: whether to read an immediate or a memory-operand displacement.
98 scan::OperandKind operand_kind = scan::OperandKind::Immediate;
99 /// CodeOperand: the index into the instruction's visible operands.
100 std::uint8_t operand_index = 0;
101 /// CodeOperand: 0 keeps the decoded value, and 1 through 8 narrows non-RIP low bytes and sign-extends.
102 std::uint8_t byte_width = 0;
103
104 /// StringXref: the exact literal content to anchor on, without quotes.
105 std::string_view xref_text;
106 /// StringXref: the byte encoding of the literal in the image (Utf16le for wchar_t literals).
107 scan::StringEncoding xref_encoding = scan::StringEncoding::Utf8;
108 /// StringXref: whether to return the referencing instruction, its enclosing function, or the pointer slot.
109 scan::XrefReturn xref_return = scan::XrefReturn::ReferencingInstruction;
110 /// StringXref: if true, match a trailing NUL so that a prefix of a longer literal does not match.
111 bool xref_require_terminator = true;
112 /// StringXref: if true, add the broad sweep for rarer reference shapes to the lea/mov shape scan.
113 bool xref_broad_match = false;
114
115 /// Manual: the pinned literal value, taken as-is unless @ref validate_manual is set.
116 std::int64_t manual_value = 0;
117
118 /** @brief An optional post-resolve predicate. For a Quorum, it runs once on the corroborated value. */
119 AnchorValidator validator = nullptr;
120 /// An opaque pointer forwarded verbatim to @ref validator.
121 const void *validator_context = nullptr;
122 /// If true, @ref validator also checks a Manual literal.
123 bool validate_manual = false;
124 /// If true, a backend-resolved anchor with no @ref validator reports Failed. Manual and Quorum are exempt.
125 bool require_validator = false;
126
127 /**
128 * @brief Quorum: the M sub-anchors that vote. Each member must outlive the resolve call. Fewer than two
129 * members, a null member, or a nested Quorum fails the quorum (@ref AnchorStatus::Failed).
130 */
131 std::span<const Anchor *const> quorum_members;
132 /**
133 * @brief Quorum: N, the minimum number of resolved members that must agree. The default 0 means all M
134 * members, and an explicit N below 2 or above M fails the quorum.
135 * @details A resolved member value qualifies as a center when at least N resolved votes agree with it.
136 * Qualified centers that disagree give @ref AnchorStatus::QuorumAmbiguous. Otherwise the smallest
137 * qualified center wins. @ref QuorumMatch::WithinTolerance measures agreement against that
138 * center, so the accepted members can span up to two tolerances.
139 */
140 std::size_t quorum_threshold = 0;
141 /// Quorum: the rule that decides whether two resolved member values agree.
142 QuorumMatch quorum_match = QuorumMatch::ExactValue;
143 /// Quorum: the tolerance for @ref QuorumMatch::WithinTolerance, where a negative value fails closed.
144 std::int64_t quorum_tolerance = 0;
145
146 /**
147 * @brief RipGlobal: the page class the byte-tier ladder scans. The @ref scan::Pages::Readable default also
148 * matches a global in `.rdata` / `.data`. If every rung anchors on an in-image instruction, set
149 * @ref scan::Pages::Executable. A data-page byte twin then cannot make a unique resolve ambiguous.
150 */
151 scan::Pages pages = scan::Pages::Readable;
152
153 /// ExportName: the module name for @ref Region::module_named, or empty for the resolve scope.
154 std::string_view export_module;
155 /// ExportName: the exact, case-sensitive export symbol name without decoration, for example "Sleep".
156 std::string_view export_name;
157 };
158
159 /** @brief What a resolved value is. See @ref manifest::GatePolicy::require_mutation_safe_binding. */
160 enum class ResultDomain : std::uint8_t
161 {
162 /// An entry that did not resolve, or a Quorum with conflicting member domains. It authorizes no mutation.
163 Unknown,
164 /// An executable instruction site (an inline-hook or mid-hook target).
165 CodeSite,
166 /// A non-executable data address (a global variable or a resolved pointer slot).
167 DataAddress,
168 /// A class vtable base, keyed on its type identity (a VMT-hook target).
169 VtableAddress,
170 /// A CodeOperand value or pinned Manual literal. It authorizes no write, even when it holds an address.
171 Scalar
172 };
173
174 /** @brief The normalized evidence backend that produced a resolved value. */
175 enum class PhysicalSource : std::uint8_t
176 {
177 /// No resolved value (failed, unsupported, or unresolved).
178 None,
179 /// A Direct / RIP-relative byte-signature cascade (an @ref AnchorKind::RipGlobal).
180 ByteSignature,
181 /// A string-literal cross-reference (an @ref AnchorKind::StringXref).
182 StringLiteral,
183 /// A reverse-RTTI vtable identity (an @ref AnchorKind::VtableIdentity).
184 TypeIdentity,
185 /// A PE export-table walk (an @ref AnchorKind::ExportName).
186 ExportTable,
187 /// A decoded in-code immediate or displacement (an @ref AnchorKind::CodeOperand).
188 CodeOperand,
189 /// A pinned Manual literal with no backend (an @ref AnchorKind::Manual).
190 ManualPin,
191 /// A value corroborated by N-of-M voting (an @ref AnchorKind::Quorum), with no single physical source.
192 Corroborated
193 };
194
195 /** @brief Whether a resolved value came from a complete, authoritative view. A partial sweep fails instead. */
196 enum class WitnessCompleteness : std::uint8_t
197 {
198 /// No assessable completeness, because the entry did not resolve.
199 Unknown,
200 /// Resolved over a complete, authoritative view of the scope.
201 Complete
202 };
203
204 /** @brief The evidence behind a resolved value, populated only on @ref AnchorStatus::Resolved. */
205 struct ResolvedWitness
206 {
207 /**
208 * @brief Identity of the module that owns the resolved address. Absent for a Scalar or synthetic address.
209 * An unreadable identity, or owner or mapping drift at validation, vote, or commit, gives
210 * @ref AnchorStatus::Failed with no witness.
211 */
212 scan::ImageIdentity image{};
213 /// The normalized backend that produced the value.
214 PhysicalSource source = PhysicalSource::None;
215 /// For a @ref PhysicalSource::CodeOperand, the decoded operand field, and otherwise unused.
216 scan::OperandKind operand_kind = scan::OperandKind::Immediate;
217 /// Whether the resolve saw a complete, authoritative view.
218 WitnessCompleteness completeness = WitnessCompleteness::Unknown;
219 /// The bytes that the byte-pattern rung matched, present only when a RipGlobal resolves on a byte rung.
220 scan::WinningEvidence evidence{};
221 };
222
223 /** @brief One entry in the drift report: the anchor's identity, outcome, and value. */
224 struct ResolvedAnchor
225 {
226 /// A copy of the @ref Anchor::label view, valid only while the characters it borrows live.
227 std::string_view label;
228 /// Copied from @ref Anchor::kind.
229 AnchorKind kind = AnchorKind::Unset;
230 /// The resolution outcome.
231 AnchorStatus status = AnchorStatus::Unresolved;
232 /** @brief An address cast to int64, or a constant. Meaningful only when @ref status is Resolved. */
233 std::int64_t value = 0;
234 /**
235 * @brief What @ref value is. A resolved entry gets @ref declared_domain, but a CodeSite at a non-executable
236 * address becomes @ref ResultDomain::DataAddress. Any other entry stays Unknown.
237 */
238 ResultDomain domain = ResultDomain::Unknown;
239 /// The evidence behind @ref value (see @ref ResolvedWitness).
240 ResolvedWitness witness{};
241 };
242
243 /** @brief A robustness summary of a drift report, the input to @ref evaluate_gate. */
244 struct AnchorQuality
245 {
246 /// Total entries in the report.
247 std::size_t total = 0;
248 /// Entries that resolved.
249 std::size_t resolved = 0;
250 /// Entries that failed closed (Failed or QuorumAmbiguous).
251 std::size_t failed = 0;
252 /// Entries whose kind has no resolver (CallArgHome).
253 std::size_t unsupported = 0;
254 /// Quorum entries rejected because their sub-anchors were not independent.
255 std::size_t not_independent = 0;
256 /// Pinned Manual literals that cannot self-heal (counted regardless of status).
257 std::size_t manual_at_risk = 0;
258 /// Corroborated quorums that resolved.
259 std::size_t corroborated = 0;
260 };
261
262 /** @brief `[B-51]` The startup decision for a drift report: enable, enable with caution, or safe-disable. */
263 enum class GateVerdict : std::uint8_t
264 {
265 /// Healthy enough to enable: within the thresholds, with no at-risk signal.
266 Pass,
267 /// Within the thresholds, but a pinned Manual literal or a report with nothing assessable adds risk.
268 Degraded,
269 /** @brief Too few anchors resolved, or too many failed. Safe-disable the feature. */
270 Fail
271 };
272
273 /** @brief The thresholds for @ref evaluate_gate. The defaults are the strictest and fail closed. */
274 struct GatePolicy
275 {
276 /** @brief Minimum resolved fraction of all non-Unsupported entries, clamped to [0, 1]. NaN means 1.0. */
277 double min_resolved_ratio = 1.0;
278 /** @brief Cap on failed plus not_independent entries. A count above it fails the gate at any ratio. */
279 std::size_t max_failed = 0;
280 /// If true, a nonzero @ref AnchorQuality::manual_at_risk count turns Pass into @ref GateVerdict::Degraded.
281 bool manual_at_risk_degrades = true;
282 };
283
284 /**
285 * @brief Turns a drift-report summary into a startup verdict.
286 * @details A report with nothing to assess is @ref GateVerdict::Degraded. Status counts above
287 * @ref AnchorQuality::total yield @ref GateVerdict::Fail.
288 * @note Callback-safe: allocation-free and side-effect-free.
289 */
290 [[nodiscard]] GateVerdict evaluate_gate(const AnchorQuality &quality, const GatePolicy &policy = {}) noexcept;
291
292 /**
293 * @brief Returns `evaluate_gate(assess_quality(report), policy)`. @p report can be a per-feature sub-span.
294 * @note Callback-safe: one allocation-free tally pass plus the threshold arithmetic.
295 */
296 [[nodiscard]] GateVerdict
297 evaluate_gate(std::span<const ResolvedAnchor> report, const GatePolicy &policy = {}) noexcept;
298
299 /** @brief Maps a @ref GateVerdict to a short static label. */
300 [[nodiscard]] std::string_view gate_verdict_to_string(GateVerdict verdict) noexcept;
301
302 /** @brief Per-game scan tuning that applies to every anchor in a profiled resolve. */
303 struct ScanProfile
304 {
305 /// Enables the broad sweep for every StringXref anchor, but never turns broad mode off.
306 bool default_broad_string_xref = false;
307 /// The candidate order for RipGlobal and CodeOperand ladders.
308 scan::CandidateOrder candidate_order = scan::CandidateOrder::AsDeclared;
309 /// A per-@ref AnchorKind deny-list. A denied backend fails closed, and no other backend replaces it.
310 std::array<bool, ANCHOR_KIND_COUNT> deny_backend{};
311
312 /** @brief Reports whether @p kind is in range and denied by this profile. */
313 414 [[nodiscard]] bool is_denied(AnchorKind kind) const noexcept
314 {
315 414 const auto index = static_cast<std::size_t>(kind);
316
4/4
✓ Branch 4 → 5 taken 413 times.
✓ Branch 4 → 8 taken 1 time.
✓ Branch 6 → 7 taken 4 times.
✓ Branch 6 → 8 taken 409 times.
828 return index < deny_backend.size() && deny_backend[index];
317 }
318 };
319
320 /** @brief Returns @p query with @ref ScanProfile::default_broad_string_xref applied. It only widens. */
321 [[nodiscard]] scan::StringRefQuery
322 apply_profile(const ScanProfile &profile, scan::StringRefQuery query) noexcept;
323
324 /**
325 * @brief Resolves one anchor through its backend, fail-closed.
326 * @param scope One module image or reserved allocation. A VtableIdentity, RipGlobal, CodeOperand, or StringXref
327 * anchor, quorum members included, fails closed if the range spans several allocations. It also
328 * fails closed if the owner or mapping at commit differs from the one captured before the scan.
329 * @note Setup/control-plane only: the backend scan can allocate and walk pages.
330 */
331 [[nodiscard]] ResolvedAnchor resolve(const Anchor &anchor, Region scope = Region::host());
332
333 /**
334 * @brief Serially resolves `min(anchors.size(), out.size())` anchors into @p out and returns that count.
335 * @note Setup/control-plane only (see @ref resolve).
336 */
337 [[nodiscard]] std::size_t
338 resolve_all(std::span<const Anchor> anchors, std::span<ResolvedAnchor> out, Region scope = Region::host());
339
340 /**
341 * @brief @ref resolve_all on a fork-join worker pool, with results in input order.
342 * @param max_workers Upper bound on threads. 0 auto-selects from `hardware_concurrency`, clamped to the count.
343 * @details Validators run concurrently. If one is order-dependent or thread-unsafe, use @ref resolve_all. If a
344 * resolve throws, this call writes a Failed entry for that anchor and does not rethrow.
345 * @note Setup/control-plane only: spawns and joins a worker pool.
346 * @warning Never call it under the loader lock, where the worker join hangs.
347 */
348 [[nodiscard]] std::size_t resolve_all_parallel(
349 std::span<const Anchor> anchors,
350 std::span<ResolvedAnchor> out,
351 Region scope = Region::host(),
352 std::size_t max_workers = 0
353 );
354
355 /**
356 * @brief Rolls a drift report into an @ref AnchorQuality without a new resolve.
357 * @note Callback-safe: one allocation-free tally pass over @p report.
358 */
359 [[nodiscard]] AnchorQuality assess_quality(std::span<const ResolvedAnchor> report) noexcept;
360
361 /**
362 * @brief Hashes an anchor's declared resolution evidence into a 64-bit FNV-1a diff key.
363 * @details The key excludes the resolved address, @ref Anchor::label, and candidate names. It is identical on
364 * every load of the same declaration, so a persisted key can serve as a drift baseline. A Quorum
365 * folds in its members independent of order, plus its effective threshold, match rule, and tolerance.
366 * @note Callback-safe: reads only the declaration, resolves nothing, and allocates nothing.
367 */
368 [[nodiscard]] std::uint64_t anchor_fingerprint(const Anchor &anchor) noexcept;
369
370 /**
371 * @brief Hashes the @ref anchor_fingerprint evidence with @p scope_identity, the identity of the module that
372 * the anchor resolves against. ASLR does not change the key. For ExportName, @p scope_identity
373 * replaces the declared module name, so inherited and explicit spellings of one module give one key.
374 * @note Callback-safe: allocation-free and side-effect-free.
375 */
376 [[nodiscard]] std::uint64_t
377 anchor_trust_fingerprint(const Anchor &anchor, scan::ImageIdentity scope_identity) noexcept;
378
379 /** @brief Maps an @ref AnchorStatus to a short static label. */
380 [[nodiscard]] std::string_view anchor_status_to_string(AnchorStatus status) noexcept;
381
382 /**
383 * @brief The @ref ResultDomain that @p anchor declares. Allocation-free and side-effect-free.
384 * @return VtableIdentity gives VtableAddress, and CodeOperand or Manual gives Scalar. StringXref gives
385 * CodeSite, or DataAddress for a StringPointerSlot return. ExportName gives a provisional CodeSite (see
386 * @ref ResolvedAnchor::domain). RipGlobal gives CodeSite only on executable @ref Anchor::pages, else
387 * DataAddress. CallArgHome, Unset, and an out-of-range enum or @ref Anchor::byte_width value in a field
388 * that the kind reads give Unknown. A Quorum gives the one address domain that its members declare,
389 * Scalar if none does, or Unknown if two members declare different ones.
390 */
391 [[nodiscard]] ResultDomain declared_domain(const Anchor &anchor) noexcept;
392
393 /** @brief Maps a @ref ResultDomain to a short static label. */
394 [[nodiscard]] std::string_view result_domain_to_string(ResultDomain domain) noexcept;
395
396 /** @brief Maps a @ref PhysicalSource to a short static label. */
397 [[nodiscard]] std::string_view physical_source_to_string(PhysicalSource source) noexcept;
398
399 /**
400 * @brief @ref resolve with the deny-list, candidate order, and broad-string default of @p profile applied.
401 * @param profile Quorum members use the same profile, so a denied member fails and casts no vote. An empty
402 * profile gives the @ref resolve result.
403 * @note Setup/control-plane only (see @ref resolve).
404 */
405 [[nodiscard]] ResolvedAnchor
406 resolve_with_profile(const Anchor &anchor, const ScanProfile &profile, Region scope = Region::host());
407
408 /**
409 * @brief @ref resolve_all with @p profile applied (see @ref resolve_with_profile).
410 * @note Setup/control-plane only (see @ref resolve).
411 */
412 [[nodiscard]] std::size_t resolve_all_with_profile(
413 std::span<const Anchor> anchors,
414 std::span<ResolvedAnchor> out,
415 const ScanProfile &profile,
416 Region scope = Region::host()
417 );
418
419 /**
420 * @brief @ref resolve_all_parallel with @p profile applied (see @ref resolve_with_profile).
421 * @note Setup/control-plane only (see @ref resolve_all_parallel).
422 * @warning Never call it under the loader lock, where the worker join hangs.
423 */
424 [[nodiscard]] std::size_t resolve_all_with_profile_parallel(
425 std::span<const Anchor> anchors,
426 std::span<ResolvedAnchor> out,
427 const ScanProfile &profile,
428 Region scope = Region::host(),
429 std::size_t max_workers = 0
430 );
431 } // namespace anchor
432 } // namespace DetourModKit
433
434 #endif // DETOURMODKIT_ANCHOR_HPP
435