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 | * @details Declares patch-fragile values once, resolves them through the existing scan/RTTI backends, and reports | ||
| 8 | * one machine-readable drift table. Missing or contradictory evidence fails closed; Quorum can require | ||
| 9 | * N-of-M independent agreement, while Manual remains the explicit pinned fallback. | ||
| 10 | * @warning `[B-100]` Under the loader lock, call only the Callback-safe trust and quality queries. Resolution can | ||
| 11 | * allocate, query loader state, scan memory, or create threads. | ||
| 12 | */ | ||
| 13 | |||
| 14 | #include "DetourModKit/error.hpp" | ||
| 15 | #include "DetourModKit/region.hpp" | ||
| 16 | #include "DetourModKit/scan.hpp" | ||
| 17 | |||
| 18 | #include <array> | ||
| 19 | #include <cstddef> | ||
| 20 | #include <cstdint> | ||
| 21 | #include <span> | ||
| 22 | #include <string_view> | ||
| 23 | |||
| 24 | namespace DetourModKit | ||
| 25 | { | ||
| 26 | namespace anchor | ||
| 27 | { | ||
| 28 | /** | ||
| 29 | * @enum AnchorKind | ||
| 30 | * @brief Which backend resolves an anchor, and therefore how update-resilient it is. | ||
| 31 | * @details When a target can be expressed more than one way, prefer the most update-resilient backend: | ||
| 32 | * ExportName > StringXref > VtableIdentity > RipGlobal > CodeOperand, with a Quorum voting over | ||
| 33 | * several of those raising confidence further and Manual as the last resort. A named export is a | ||
| 34 | * module's documented ABI; a string literal and a mangled type name survive game patches far better | ||
| 35 | * than the code bytes and addresses around them. | ||
| 36 | */ | ||
| 37 | enum class AnchorKind : std::uint8_t | ||
| 38 | { | ||
| 39 | /// A class vtable address, keyed on its mangled name, via @ref rtti::vtable_for_type. | ||
| 40 | VtableIdentity, | ||
| 41 | /// An absolute address (Direct or RIP-relative candidate cascade), via @ref scan::resolve. | ||
| 42 | RipGlobal, | ||
| 43 | /// An in-code immediate or `[reg + disp]` displacement, via @ref scan::read_code_constant. | ||
| 44 | CodeOperand, | ||
| 45 | /** | ||
| 46 | * @brief The instruction (or enclosing function) that references an immutable string literal, via | ||
| 47 | * @ref scan::find_string_xref. | ||
| 48 | */ | ||
| 49 | StringXref, | ||
| 50 | /// A pinned literal with no backend; reported as at-risk because it cannot self-heal. | ||
| 51 | Manual, | ||
| 52 | /** | ||
| 53 | * @brief Reserved for a future prologue-dataflow backend (a call argument to its register/stack home). | ||
| 54 | * Declaring it now keeps a registry table forward-compatible; it currently reports | ||
| 55 | * @ref AnchorStatus::Unsupported. | ||
| 56 | */ | ||
| 57 | CallArgHome, | ||
| 58 | /** | ||
| 59 | * @brief A corroborated value accepted only when at least N of M independent sub-anchors resolve and agree | ||
| 60 | * (N-of-M voting). | ||
| 61 | */ | ||
| 62 | Quorum, | ||
| 63 | /** | ||
| 64 | * @brief A named export resolved by walking its module's PE Export Address Table, via | ||
| 65 | * @ref scan::resolve_export. Uses @ref Anchor::export_name and the optional | ||
| 66 | * @ref Anchor::export_module (an empty module resolves within the resolve scope). | ||
| 67 | */ | ||
| 68 | ExportName, | ||
| 69 | /** | ||
| 70 | * @brief No backend: the fail-closed default for an anchor whose @ref Anchor::kind was never set. An | ||
| 71 | * aggregate table entry that omits the kind reports @ref AnchorStatus::Failed instead of a trusted | ||
| 72 | * address 0. | ||
| 73 | */ | ||
| 74 | Unset | ||
| 75 | }; | ||
| 76 | |||
| 77 | /// The number of @ref AnchorKind enumerators; sizes the per-kind deny-list in @ref ScanProfile. | ||
| 78 | inline constexpr std::size_t ANCHOR_KIND_COUNT = 9; | ||
| 79 | static_assert( | ||
| 80 | static_cast<std::size_t>(AnchorKind::Unset) + 1 == ANCHOR_KIND_COUNT, | ||
| 81 | "ANCHOR_KIND_COUNT must track the AnchorKind enumerator count." | ||
| 82 | ); | ||
| 83 | |||
| 84 | /** | ||
| 85 | * @enum QuorumMatch | ||
| 86 | * @brief The agreement policy a @ref AnchorKind::Quorum applies when deciding whether two resolved member | ||
| 87 | * values count as one vote for the same target. | ||
| 88 | */ | ||
| 89 | enum class QuorumMatch : std::uint8_t | ||
| 90 | { | ||
| 91 | /// Two member values agree only when identical (the default, strongest policy). | ||
| 92 | ExactValue, | ||
| 93 | /** | ||
| 94 | * @brief Two member values agree when their gap is at most @ref Anchor::quorum_tolerance; a negative | ||
| 95 | * tolerance fails closed (never accepts). The pairwise-independence gate prevents a false | ||
| 96 | * near-value cluster from two members that decode adjacent bytes. | ||
| 97 | */ | ||
| 98 | WithinTolerance | ||
| 99 | }; | ||
| 100 | |||
| 101 | /** | ||
| 102 | * @enum AnchorStatus | ||
| 103 | * @brief The outcome of resolving one anchor. | ||
| 104 | */ | ||
| 105 | enum class AnchorStatus : std::uint8_t | ||
| 106 | { | ||
| 107 | /// The initial state written into an untouched slot; a resolved report never leaves this in place. | ||
| 108 | Unresolved, | ||
| 109 | /// The backend resolved a value and every applicable validator/corroboration check passed. | ||
| 110 | Resolved, | ||
| 111 | /** | ||
| 112 | * @brief The backend missed, a validator rejected the value, a denied backend was requested, or a quorum | ||
| 113 | * disagreed; no value is invented (fail closed). | ||
| 114 | */ | ||
| 115 | Failed, | ||
| 116 | /// The kind has no resolver yet (@ref AnchorKind::CallArgHome). | ||
| 117 | Unsupported, | ||
| 118 | /// A quorum's members were not all pairwise-independent evidence, so corroboration would be meaningless. | ||
| 119 | QuorumNotIndependent, | ||
| 120 | /** | ||
| 121 | * @brief A quorum reached its threshold for two or more values that do not agree with each other, so no | ||
| 122 | * single value is corroborated. Declaration order must not silently pick a winner, so the vote | ||
| 123 | * fails closed. | ||
| 124 | */ | ||
| 125 | QuorumAmbiguous | ||
| 126 | }; | ||
| 127 | |||
| 128 | /** | ||
| 129 | * @brief A post-resolve validator predicate: returns false to fail an otherwise-resolved anchor closed. | ||
| 130 | * @details Runs on the resolved value just before it is accepted. Returning false resets the value to 0 and | ||
| 131 | * sets @ref AnchorStatus::Failed, identical to a backend miss, so the caller re-heals by re-resolving. | ||
| 132 | * Use it to assert a domain invariant a generic backend cannot know (the target lies in an expected | ||
| 133 | * sub-range, a displacement points into `.rdata`, the site begins with a plausible prologue). | ||
| 134 | * @param value The resolved value (an address cast to int64, an in-code constant, or the manual literal). | ||
| 135 | * @param context The opaque @ref Anchor::validator_context pointer, forwarded verbatim (nullptr if unused). | ||
| 136 | */ | ||
| 137 | using AnchorValidator = bool (*)(std::int64_t value, const void *context) noexcept; | ||
| 138 | |||
| 139 | /** | ||
| 140 | * @struct Anchor | ||
| 141 | * @brief One declarative registry entry: what to resolve, how, and how to verify it. Authored as a static | ||
| 142 | * table. | ||
| 143 | * @details A flat aggregate authored with designated initializers, so a table lists only the fields its kind | ||
| 144 | * uses and leaves the rest defaulted. The active field set depends on @ref kind; other kinds' fields | ||
| 145 | * are ignored. All views (@ref label, @ref mangled, @ref site, @ref xref_text) are non-owning and must | ||
| 146 | * outlive the resolve call. The canonical use is a `static constexpr`/`static const` table whose | ||
| 147 | * storage lives for the process. | ||
| 148 | */ | ||
| 149 | struct Anchor | ||
| 150 | { | ||
| 151 | /// Identifier echoed into the @ref ResolvedAnchor; excluded from @ref anchor_fingerprint. | ||
| 152 | std::string_view label; | ||
| 153 | /// Which backend resolves this anchor. Defaults to @ref AnchorKind::Unset so an omitted kind fails closed. | ||
| 154 | AnchorKind kind = AnchorKind::Unset; | ||
| 155 | |||
| 156 | /// VtableIdentity: the MSVC mangled type name, e.g. ".?AVGameAudioEffect@engine@@". | ||
| 157 | std::string_view mangled; | ||
| 158 | |||
| 159 | /** | ||
| 160 | * @brief RipGlobal / CodeOperand: the candidate ladder resolving to the address or the instruction site. | ||
| 161 | * Borrowed. | ||
| 162 | */ | ||
| 163 | std::span<const scan::Candidate> site; | ||
| 164 | /// CodeOperand: whether to read an immediate or a memory-operand displacement. | ||
| 165 | scan::OperandKind operand_kind = scan::OperandKind::Immediate; | ||
| 166 | /// CodeOperand: index into the instruction's VISIBLE operands. | ||
| 167 | std::uint8_t operand_index = 0; | ||
| 168 | /// CodeOperand: 0 preserves the decoded value; 1 through 8 narrows non-RIP low bytes and sign-extends. | ||
| 169 | std::uint8_t byte_width = 0; | ||
| 170 | |||
| 171 | /// StringXref: the exact literal content to anchor on (no quotes). Borrowed. | ||
| 172 | std::string_view xref_text; | ||
| 173 | /// StringXref: byte encoding of the literal in the image (Utf16le for wchar_t literals). | ||
| 174 | scan::StringEncoding xref_encoding = scan::StringEncoding::Utf8; | ||
| 175 | /// StringXref: whether to return the referencing instruction, its enclosing function, or the pointer slot. | ||
| 176 | scan::XrefReturn xref_return = scan::XrefReturn::ReferencingInstruction; | ||
| 177 | /// StringXref: match a trailing NUL so a prefix of a longer literal is not matched. | ||
| 178 | bool xref_require_terminator = true; | ||
| 179 | /// StringXref: keep the lea/mov shape scan and add the broad Zydis sweep for rarer reference shapes. | ||
| 180 | bool xref_broad_match = false; | ||
| 181 | |||
| 182 | /// Manual: the pinned literal value, taken as-is (unless @ref validate_manual runs the validator on it). | ||
| 183 | std::int64_t manual_value = 0; | ||
| 184 | |||
| 185 | /** | ||
| 186 | * @brief Optional post-resolve predicate; nullptr skips validation. Never applied to Manual unless | ||
| 187 | * @ref validate_manual, nor to CallArgHome; for a Quorum it runs once on the corroborated value. | ||
| 188 | */ | ||
| 189 | AnchorValidator validator = nullptr; | ||
| 190 | /// Opaque pointer forwarded verbatim to @ref validator. | ||
| 191 | const void *validator_context = nullptr; | ||
| 192 | /// Run @ref validator on a Manual anchor too, instead of taking the pinned literal unchecked. | ||
| 193 | bool validate_manual = false; | ||
| 194 | /** | ||
| 195 | * @brief Reject a backend-resolvable anchor that carries no @ref validator (status Failed). Only the five | ||
| 196 | * backend kinds (VtableIdentity, RipGlobal, CodeOperand, StringXref, ExportName) are subject to | ||
| 197 | * this: | ||
| 198 | * a pinned Manual literal and a Quorum are both exempt. A Manual is not a resolved target, and a | ||
| 199 | * Quorum's N-of-M corroboration is already the verification. | ||
| 200 | */ | ||
| 201 | bool require_validator = false; | ||
| 202 | |||
| 203 | /** | ||
| 204 | * @brief Quorum: the M candidate sub-anchors that vote on the target. Non-owning pointers into the caller's | ||
| 205 | * own anchor storage; every member must outlive the resolve call. The quorum fails closed (status | ||
| 206 | * @ref AnchorStatus::Failed) on a malformed declaration - fewer than two members, a null member, or | ||
| 207 | * a member that is itself a Quorum (nesting is bounded to one level). | ||
| 208 | */ | ||
| 209 | std::span<const Anchor *const> quorum_members; | ||
| 210 | /** | ||
| 211 | * @brief Quorum: N, the minimum number of members that must resolve AND agree for the quorum to accept | ||
| 212 | * (N-of-M voting). 0 (the default) means unanimous: every member in @ref quorum_members must | ||
| 213 | * agree, so a two-member quorum with the default is the strict 2-of-2 corroboration. A quorum is | ||
| 214 | * corroboration, so an explicit N below 2 or above the member count is a malformed vote and fails | ||
| 215 | * the quorum closed rather than degrading to a single signal. | ||
| 216 | * @details Vote semantics: each resolved member value is a candidate center, and a center qualifies when | ||
| 217 | * at least N resolved votes agree with it. Two qualified centers that disagree yield | ||
| 218 | * @ref AnchorStatus::QuorumAmbiguous. Otherwise the vote commits the smallest qualified center, | ||
| 219 | * independent of member order. Under @ref QuorumMatch::WithinTolerance agreement is measured | ||
| 220 | * against that center, so the accepted members can span up to two tolerances. | ||
| 221 | */ | ||
| 222 | std::size_t quorum_threshold = 0; | ||
| 223 | /// Quorum: how two resolved member values must relate for a vote to count them as agreeing. | ||
| 224 | QuorumMatch quorum_match = QuorumMatch::ExactValue; | ||
| 225 | /// Quorum: the tolerance for @ref QuorumMatch::WithinTolerance (a negative tolerance fails closed). | ||
| 226 | std::int64_t quorum_tolerance = 0; | ||
| 227 | |||
| 228 | /** | ||
| 229 | * @brief RipGlobal: page-protection class the byte-tier ladder scans. The @ref scan::Pages::Readable | ||
| 230 | * default lets a Direct rung resolve a plain global in `.rdata` / `.data`. Set | ||
| 231 | * @ref scan::Pages::Executable when every rung anchors on an in-image instruction. A byte twin in a | ||
| 232 | * data page then cannot demote a unique resolve to a fail-closed ambiguity. Ignored by CodeOperand | ||
| 233 | * and non-scan kinds. | ||
| 234 | */ | ||
| 235 | scan::Pages pages = scan::Pages::Readable; | ||
| 236 | |||
| 237 | /** | ||
| 238 | * @brief ExportName: the module whose Export Address Table holds the export, e.g. "kernel32.dll". Empty | ||
| 239 | * (the default) resolves the export within the same @p scope the anchor is resolved against, so an | ||
| 240 | * anchor on the scanned module's own export needs no module name. A non-empty name is looked up | ||
| 241 | * through @ref Region::module_named at resolve time, so an ExportName in a foreign module (one | ||
| 242 | * independent of the table's shared scan scope) resolves correctly. Borrowed; ignored by every other | ||
| 243 | * kind. | ||
| 244 | */ | ||
| 245 | std::string_view export_module; | ||
| 246 | /// ExportName: the exact, case-sensitive export symbol name (no decoration), e.g. "Sleep". Borrowed. | ||
| 247 | std::string_view export_name; | ||
| 248 | }; | ||
| 249 | |||
| 250 | /** | ||
| 251 | * @enum ResultDomain | ||
| 252 | * @brief What a resolved anchor's value IS, so a binding cannot mutate through an incompatible target. | ||
| 253 | * @details From @ref declared_domain: a mid-hook binding needs a @ref CodeSite, a VMT binding a | ||
| 254 | * @ref VtableAddress, an address / pointer-chain write a real address (CodeSite or DataAddress). A | ||
| 255 | * @ref Scalar is a constant, not an address, and authorizes no write; @ref Unknown is the fail-closed | ||
| 256 | * default an unresolved or unsupported entry keeps. | ||
| 257 | */ | ||
| 258 | enum class ResultDomain : std::uint8_t | ||
| 259 | { | ||
| 260 | /// A failed, unresolved, or unsupported entry: no resolved target. Authorizes no mutation. | ||
| 261 | Unknown, | ||
| 262 | /// An executable instruction site (an inline-hook or mid-hook target). | ||
| 263 | CodeSite, | ||
| 264 | /// A non-executable data address (a global variable or a resolved pointer slot). | ||
| 265 | DataAddress, | ||
| 266 | /// A class vtable base, keyed on its type identity (a VMT-hook target). | ||
| 267 | VtableAddress, | ||
| 268 | /// A decoded constant, not an address (a code immediate / displacement or a pinned Manual literal). | ||
| 269 | Scalar | ||
| 270 | }; | ||
| 271 | |||
| 272 | /** | ||
| 273 | * @enum PhysicalSource | ||
| 274 | * @brief The normalized evidence backend that produced a resolved value. | ||
| 275 | */ | ||
| 276 | enum class PhysicalSource : std::uint8_t | ||
| 277 | { | ||
| 278 | /// No resolved value (failed, unsupported, or unresolved). | ||
| 279 | None, | ||
| 280 | /// A Direct / RIP-relative byte-signature cascade (an @ref AnchorKind::RipGlobal). | ||
| 281 | ByteSignature, | ||
| 282 | /// A string-literal cross-reference (an @ref AnchorKind::StringXref). | ||
| 283 | StringLiteral, | ||
| 284 | /// A reverse-RTTI vtable identity (an @ref AnchorKind::VtableIdentity). | ||
| 285 | TypeIdentity, | ||
| 286 | /// A PE export-table walk (an @ref AnchorKind::ExportName). | ||
| 287 | ExportTable, | ||
| 288 | /// A decoded in-code immediate or displacement (an @ref AnchorKind::CodeOperand). | ||
| 289 | CodeOperand, | ||
| 290 | /// A pinned Manual literal with no backend (an @ref AnchorKind::Manual). | ||
| 291 | ManualPin, | ||
| 292 | /// A value corroborated by N-of-M voting (an @ref AnchorKind::Quorum), with no single physical source. | ||
| 293 | Corroborated | ||
| 294 | }; | ||
| 295 | |||
| 296 | /** | ||
| 297 | * @enum WitnessCompleteness | ||
| 298 | * @brief Whether a resolved value came from a complete, authoritative view of its scope. | ||
| 299 | * @details A truncated or unauthoritative sweep fails before it can produce @ref Complete. | ||
| 300 | */ | ||
| 301 | enum class WitnessCompleteness : std::uint8_t | ||
| 302 | { | ||
| 303 | /// No assessable completeness (the entry did not resolve). | ||
| 304 | Unknown, | ||
| 305 | /// Resolved over a complete, authoritative view of the scope. | ||
| 306 | Complete | ||
| 307 | }; | ||
| 308 | |||
| 309 | /** | ||
| 310 | * @struct ResolvedWitness | ||
| 311 | * @brief The image, source, and completeness evidence carried by a resolved anchor. | ||
| 312 | * @details Populated only on @ref AnchorStatus::Resolved. Scalar values carry no image identity. | ||
| 313 | */ | ||
| 314 | struct ResolvedWitness | ||
| 315 | { | ||
| 316 | /** | ||
| 317 | * @brief Identity of the module owning the resolved address; absent for a Scalar or synthetic address. | ||
| 318 | * @details Copies the accepted value-owner identity. Missing identity or owner/mapping drift through | ||
| 319 | * validation, quorum voting, or commit yields @ref AnchorStatus::Failed with no witness. | ||
| 320 | */ | ||
| 321 | scan::ImageIdentity image{}; | ||
| 322 | /// The normalized backend that produced the value. | ||
| 323 | PhysicalSource source = PhysicalSource::None; | ||
| 324 | /// For a @ref PhysicalSource::CodeOperand, which operand field was decoded; otherwise unused. | ||
| 325 | scan::OperandKind operand_kind = scan::OperandKind::Immediate; | ||
| 326 | /// Whether the resolve saw a complete, authoritative view. | ||
| 327 | WitnessCompleteness completeness = WitnessCompleteness::Unknown; | ||
| 328 | /** | ||
| 329 | * @brief The literal bytes of the span the winning byte-pattern rung matched; absent for every other kind. | ||
| 330 | * @details Present only when @ref AnchorKind::RipGlobal wins on a byte-pattern rung. Structural rungs | ||
| 331 | * and scalar results carry no matched span. | ||
| 332 | */ | ||
| 333 | scan::WinningEvidence evidence{}; | ||
| 334 | }; | ||
| 335 | |||
| 336 | /** | ||
| 337 | * @struct ResolvedAnchor | ||
| 338 | * @brief One resolved entry in the drift report: the anchor's identity plus its outcome and value. | ||
| 339 | * @details The report array is the drift report itself: walk it once at init to log what resolved, what failed, | ||
| 340 | * and what is a pinned Manual literal and therefore at risk. @ref value is meaningful only when | ||
| 341 | * @ref status is @ref AnchorStatus::Resolved, and carries the quantity interpreted per @ref kind (a | ||
| 342 | * vtable or global address cast to int64, an in-code constant, or the manual literal). | ||
| 343 | */ | ||
| 344 | struct ResolvedAnchor | ||
| 345 | { | ||
| 346 | /** | ||
| 347 | * @brief A borrowed view of @ref Anchor::label, not an owned copy: it aliases the source anchor's storage | ||
| 348 | * and shares its lifetime. Valid only while that anchor (canonically a `static` table entry that | ||
| 349 | * lives for the process) outlives the report; copy into owned storage before the source can end. | ||
| 350 | */ | ||
| 351 | std::string_view label; | ||
| 352 | /// Copied from @ref Anchor::kind. | ||
| 353 | AnchorKind kind = AnchorKind::Unset; | ||
| 354 | /// The resolution outcome. | ||
| 355 | AnchorStatus status = AnchorStatus::Unresolved; | ||
| 356 | /// The resolved quantity, meaningful only when @ref status is @ref AnchorStatus::Resolved. | ||
| 357 | std::int64_t value = 0; | ||
| 358 | /** | ||
| 359 | * @brief What @ref value is, for binding-compatibility gating (see @ref ResultDomain). Set from | ||
| 360 | * @ref declared_domain when the entry resolves; @ref ResultDomain::Unknown otherwise. | ||
| 361 | */ | ||
| 362 | ResultDomain domain = ResultDomain::Unknown; | ||
| 363 | /** | ||
| 364 | * @brief The resolved value's semantic-site witness; empty unless @ref status is Resolved. | ||
| 365 | */ | ||
| 366 | ResolvedWitness witness{}; | ||
| 367 | }; | ||
| 368 | |||
| 369 | /** | ||
| 370 | * @struct AnchorQuality | ||
| 371 | * @brief A one-pass robustness summary of a drift report, for gating "is this manifest healthy enough to run?". | ||
| 372 | */ | ||
| 373 | struct AnchorQuality | ||
| 374 | { | ||
| 375 | /// Total entries in the report. | ||
| 376 | std::size_t total = 0; | ||
| 377 | /// Entries that resolved. | ||
| 378 | std::size_t resolved = 0; | ||
| 379 | /// Entries that failed closed. | ||
| 380 | std::size_t failed = 0; | ||
| 381 | /// Entries whose kind has no resolver yet (CallArgHome). | ||
| 382 | std::size_t unsupported = 0; | ||
| 383 | /// Quorum entries rejected because their sub-anchors were not independent. | ||
| 384 | std::size_t not_independent = 0; | ||
| 385 | /// Pinned Manual literals that cannot self-heal (counted regardless of status). | ||
| 386 | std::size_t manual_at_risk = 0; | ||
| 387 | /// Corroborated quorums that resolved (the strongest evidence). | ||
| 388 | std::size_t corroborated = 0; | ||
| 389 | }; | ||
| 390 | |||
| 391 | /** | ||
| 392 | * @enum GateVerdict | ||
| 393 | * @brief The startup decision a drift report yields: enable, enable-with-caution, or safe-disable. | ||
| 394 | * @details `[B-51]` The verdict lets a mod disable a feature before it uses unverified addresses. It prevents a | ||
| 395 | * low-quality log followed by a game-memory patch. | ||
| 396 | */ | ||
| 397 | enum class GateVerdict : std::uint8_t | ||
| 398 | { | ||
| 399 | /// Healthy enough to enable outright: the resolve ratio met the threshold and no at-risk signal fired. | ||
| 400 | Pass, | ||
| 401 | /** | ||
| 402 | * @brief Resolved above the threshold, but a soft signal (a pinned Manual literal that cannot self-heal, | ||
| 403 | * or a report with nothing assessable) marks the resolution at-risk. The caller decides how to | ||
| 404 | * treat the risk. | ||
| 405 | */ | ||
| 406 | Degraded, | ||
| 407 | /** | ||
| 408 | * @brief Below the threshold - too few anchors resolved, or too many failed. Safe-disable the feature | ||
| 409 | * rather than run it on addresses the manifest could not verify. | ||
| 410 | */ | ||
| 411 | Fail | ||
| 412 | }; | ||
| 413 | |||
| 414 | /** | ||
| 415 | * @struct GatePolicy | ||
| 416 | * @brief The thresholds that turn an @ref AnchorQuality summary into a @ref GateVerdict. Defaults fail closed. | ||
| 417 | * @details A plain value with no global state, so a mod can hold one policy per feature (a cosmetic overlay can | ||
| 418 | * tolerate a lower ratio than a frame-time camera patch that writes a live pointer). The defaults are | ||
| 419 | * the strictest: every resolvable anchor must heal and nothing may fail. | ||
| 420 | */ | ||
| 421 | struct GatePolicy | ||
| 422 | { | ||
| 423 | /** | ||
| 424 | * @brief Minimum fraction, in [0, 1], of RESOLVABLE anchors (@ref AnchorQuality::total minus the | ||
| 425 | * unsupported @ref AnchorKind::CallArgHome kind) that must resolve for the gate to pass. A | ||
| 426 | * caller-supplied value outside [0, 1] is clamped; NaN is treated as the strict default. The default | ||
| 427 | * 1.0 requires every resolvable anchor to heal. | ||
| 428 | */ | ||
| 429 | double min_resolved_ratio = 1.0; | ||
| 430 | /** | ||
| 431 | * @brief Hard cap on non-resolving failures (@ref AnchorQuality::failed plus @ref | ||
| 432 | * AnchorQuality::not_independent); exceeding it fails the gate regardless of the ratio. The default | ||
| 433 | * 0 tolerates no failure. | ||
| 434 | */ | ||
| 435 | std::size_t max_failed = 0; | ||
| 436 | /** | ||
| 437 | * @brief When true (the default), any counted @ref AnchorQuality::manual_at_risk entry downgrades an | ||
| 438 | * otherwise-passing verdict to @ref GateVerdict::Degraded, because a Manual literal cannot | ||
| 439 | * self-heal across a patch. | ||
| 440 | */ | ||
| 441 | bool manual_at_risk_degrades = true; | ||
| 442 | }; | ||
| 443 | |||
| 444 | /** | ||
| 445 | * @brief Turns a drift-report robustness summary into a startup enable/disable decision. | ||
| 446 | * @param quality The summary from @ref assess_quality (or @ref diagnostics::Snapshot::anchor_quality). | ||
| 447 | * @param policy The thresholds; the default policy fails closed (every resolvable anchor must heal, zero | ||
| 448 | * failures tolerated). | ||
| 449 | * @return @ref GateVerdict::Fail when the report is below the threshold (safe-disable the feature), @ref | ||
| 450 | * GateVerdict::Degraded when it resolved but carries a soft risk, else @ref GateVerdict::Pass. | ||
| 451 | * @details For feature-granular gating, gate a sub-span of a shared report. The ratio denominator excludes | ||
| 452 | * the unsupported @ref AnchorKind::CallArgHome kind, which has no resolver. Every resolvable entry | ||
| 453 | * that did not resolve (a Failed anchor, a QuorumNotIndependent one, an untouched Unresolved slot) | ||
| 454 | * stays in the denominator, so a partial resolve fails closed. A report with nothing to assess is | ||
| 455 | * @ref GateVerdict::Degraded, never a false Pass. A hand-built @ref AnchorQuality whose status | ||
| 456 | * counts exceed @ref AnchorQuality::total fails closed to @ref GateVerdict::Fail. | ||
| 457 | * @note Callback-safe: pure threshold arithmetic over @p quality, allocation-free and side-effect-free. | ||
| 458 | */ | ||
| 459 | [[nodiscard]] GateVerdict evaluate_gate(const AnchorQuality &quality, const GatePolicy &policy = {}) noexcept; | ||
| 460 | |||
| 461 | /** | ||
| 462 | * @brief Summarizes a drift report and gates it in one call. | ||
| 463 | * @param report The @ref ResolvedAnchor array (or a per-feature sub-span) produced by a resolve_all variant. | ||
| 464 | * @param policy The gate thresholds. | ||
| 465 | * @return The gate verdict for @p report under @p policy; equivalent to | ||
| 466 | * `evaluate_gate(assess_quality(report), policy)`. | ||
| 467 | * @note Callback-safe: one allocation-free tally pass plus the threshold arithmetic. | ||
| 468 | */ | ||
| 469 | [[nodiscard]] GateVerdict | ||
| 470 | evaluate_gate(std::span<const ResolvedAnchor> report, const GatePolicy &policy = {}) noexcept; | ||
| 471 | |||
| 472 | /** | ||
| 473 | * @brief Maps a @ref GateVerdict to a short human-readable label. | ||
| 474 | * @param verdict The verdict. | ||
| 475 | * @return A static string view naming the verdict. | ||
| 476 | */ | ||
| 477 | [[nodiscard]] std::string_view gate_verdict_to_string(GateVerdict verdict) noexcept; | ||
| 478 | |||
| 479 | /** | ||
| 480 | * @struct ScanProfile | ||
| 481 | * @brief A per-game bundle of setup-only scan-tuning DEFAULTS, applied as a plain value with no global state. | ||
| 482 | * @details It supplies defaults only: an explicit per-anchor choice still wins, so wiring a profile never | ||
| 483 | * overrides an explicit setting. The plain @ref resolve / @ref resolve_all are equivalent to resolving | ||
| 484 | * with an empty profile. | ||
| 485 | */ | ||
| 486 | struct ScanProfile | ||
| 487 | { | ||
| 488 | /** | ||
| 489 | * @brief Widen the broad string-xref sweep on for StringXref anchors. It can only widen: a per-anchor | ||
| 490 | * @ref Anchor::xref_broad_match still wins, so this never forces broad mode off. | ||
| 491 | */ | ||
| 492 | bool default_broad_string_xref = false; | ||
| 493 | /// The candidate ordering applied to RipGlobal / CodeOperand ladders (reuses the scan module's policy). | ||
| 494 | scan::CandidateOrder candidate_order = scan::CandidateOrder::AsDeclared; | ||
| 495 | /// A per-@ref AnchorKind deny-list. A denied backend fails closed (never silently replaced by another). | ||
| 496 | std::array<bool, ANCHOR_KIND_COUNT> deny_backend{}; | ||
| 497 | |||
| 498 | /** | ||
| 499 | * @brief Reports whether @p kind's backend is denied by this profile. | ||
| 500 | * @param kind The anchor kind to test. | ||
| 501 | * @return true when the kind is in range and its deny-list slot is set. | ||
| 502 | */ | ||
| 503 | 411 | [[nodiscard]] bool is_denied(AnchorKind kind) const noexcept | |
| 504 | { | ||
| 505 | 411 | const auto index = static_cast<std::size_t>(kind); | |
| 506 |
4/4✓ Branch 4 → 5 taken 410 times.
✓ Branch 4 → 8 taken 1 time.
✓ Branch 6 → 7 taken 4 times.
✓ Branch 6 → 8 taken 406 times.
|
822 | return index < deny_backend.size() && deny_backend[index]; |
| 507 | } | ||
| 508 | }; | ||
| 509 | |||
| 510 | /** | ||
| 511 | * @brief Applies a profile's string-xref defaults to a query, widening broad-match only. | ||
| 512 | * @param profile The profile whose defaults to apply. | ||
| 513 | * @param query The base query (typically built from an anchor's xref_* fields). | ||
| 514 | * @return The query with @ref ScanProfile::default_broad_string_xref folded in (widen-only: an already-broad | ||
| 515 | * query stays broad, never downgraded). | ||
| 516 | */ | ||
| 517 | [[nodiscard]] scan::StringRefQuery | ||
| 518 | apply_profile(const ScanProfile &profile, scan::StringRefQuery query) noexcept; | ||
| 519 | |||
| 520 | /** | ||
| 521 | * @brief Resolves one anchor through its backend, fail-closed. | ||
| 522 | * @param anchor The anchor to resolve. | ||
| 523 | * @param scope One module image or reserved allocation to resolve within; defaults to the host executable. | ||
| 524 | * Scoping is load-bearing: the same vtable name or instruction shape can exist in several loaded | ||
| 525 | * modules, so a scope-backed anchor fails closed when the range crosses allocation boundaries. | ||
| 526 | * @return A @ref ResolvedAnchor carrying the outcome and (on success) the value. | ||
| 527 | * @details Rechecks the scope's single-allocation identity through commit, including scope-backed quorum | ||
| 528 | * members. An explicit ExportName module may differ from the common scope. | ||
| 529 | * @note Setup/control-plane only: the resolve runs its backend scan, which can allocate and walk pages. | ||
| 530 | */ | ||
| 531 | [[nodiscard]] ResolvedAnchor resolve(const Anchor &anchor, Region scope = Region::host()); | ||
| 532 | |||
| 533 | /** | ||
| 534 | * @brief Resolves a table of anchors serially, writing one @ref ResolvedAnchor per input. | ||
| 535 | * @param anchors The anchor table. | ||
| 536 | * @param out The report buffer; at most `min(anchors.size(), out.size())` entries are written. | ||
| 537 | * @param scope The module image to resolve within. | ||
| 538 | * @return The number of entries written. | ||
| 539 | * @note Setup/control-plane only (see @ref resolve). | ||
| 540 | */ | ||
| 541 | [[nodiscard]] std::size_t | ||
| 542 | resolve_all(std::span<const Anchor> anchors, std::span<ResolvedAnchor> out, Region scope = Region::host()); | ||
| 543 | |||
| 544 | /** | ||
| 545 | * @brief Resolves a table of independent anchors concurrently through a fork-join worker pool. | ||
| 546 | * @param anchors The anchor table. | ||
| 547 | * @param out The report buffer; at most `min(anchors.size(), out.size())` entries are written, in input order. | ||
| 548 | * @param scope The module image to resolve within. | ||
| 549 | * @param max_workers Upper bound on worker threads (0 = auto-select from hardware_concurrency, clamped). | ||
| 550 | * @return The number of entries written. | ||
| 551 | * @details Each anchor still goes through the single-anchor @ref resolve path, so backend failures, validators, | ||
| 552 | * quorum checks, and result ordering all match @ref resolve_all. It is opt-in because validators run | ||
| 553 | * concurrently; use the serial @ref resolve_all when a validator context is order-dependent or must be | ||
| 554 | * externally serialized. | ||
| 555 | * @note Setup/control-plane only: spawns a worker pool. Never call it from a hook or under the loader lock. | ||
| 556 | */ | ||
| 557 | [[nodiscard]] std::size_t resolve_all_parallel( | ||
| 558 | std::span<const Anchor> anchors, | ||
| 559 | std::span<ResolvedAnchor> out, | ||
| 560 | Region scope = Region::host(), | ||
| 561 | std::size_t max_workers = 0 | ||
| 562 | ); | ||
| 563 | |||
| 564 | /** | ||
| 565 | * @brief Rolls a drift report into an @ref AnchorQuality summary in one allocation-free pass (no re-resolve). | ||
| 566 | * @param report The @ref ResolvedAnchor array produced by a resolve_all variant. | ||
| 567 | * @return The tallied summary. | ||
| 568 | * @note Callback-safe: one allocation-free tally pass over @p report. | ||
| 569 | */ | ||
| 570 | [[nodiscard]] AnchorQuality assess_quality(std::span<const ResolvedAnchor> report) noexcept; | ||
| 571 | |||
| 572 | /** | ||
| 573 | * @brief Hashes an anchor's resolution EVIDENCE into a stable 64-bit diff key, excluding the resolved address. | ||
| 574 | * @param anchor The anchor to fingerprint. | ||
| 575 | * @return A 64-bit FNV-1a hash of the declarative inputs the backend uses. | ||
| 576 | * @details The fingerprint excludes the resolved address, the cosmetic @ref Anchor::label, and the candidate | ||
| 577 | * names, so it stays stable when only the address drifts. Persist it next to each resolved value. A | ||
| 578 | * moved value with the same fingerprint is self-healed drift. A changed fingerprint means that the | ||
| 579 | * signature itself changed and needs a new review. A byte tier hashes the | ||
| 580 | * compiled Pattern's bytes, mask, and decode parameters. A Quorum combines every member's evidence | ||
| 581 | * order-independently and folds in the effective vote threshold, agreement mode, and tolerance. It | ||
| 582 | * reads only the declarative views, resolves nothing, and allocates nothing. | ||
| 583 | * @note Callback-safe: allocation-free and side-effect-free (see @ref anchor_trust_fingerprint). | ||
| 584 | */ | ||
| 585 | [[nodiscard]] std::uint64_t anchor_fingerprint(const Anchor &anchor) noexcept; | ||
| 586 | |||
| 587 | /** | ||
| 588 | * @brief Hashes an anchor's definition evidence together with its effective live-image identity. | ||
| 589 | * @param anchor The anchor to fingerprint. | ||
| 590 | * @param scope_identity The @ref scan::ImageIdentity of the module the anchor effectively resolves against | ||
| 591 | * (@ref scan::image_identity of that module). | ||
| 592 | * @return A 64-bit scope-bound trust key. | ||
| 593 | * @details ASLR does not affect the key. For @ref AnchorKind::ExportName, the effective identity replaces the | ||
| 594 | * declared module spelling so inherited and explicit spellings of the same module agree. | ||
| 595 | * @note Callback-safe: allocation-free and side-effect-free. | ||
| 596 | */ | ||
| 597 | [[nodiscard]] std::uint64_t | ||
| 598 | anchor_trust_fingerprint(const Anchor &anchor, scan::ImageIdentity scope_identity) noexcept; | ||
| 599 | |||
| 600 | /** | ||
| 601 | * @brief Maps an @ref AnchorStatus to a short human-readable label. | ||
| 602 | * @param status The status. | ||
| 603 | * @return A static string view naming the status. | ||
| 604 | */ | ||
| 605 | [[nodiscard]] std::string_view anchor_status_to_string(AnchorStatus status) noexcept; | ||
| 606 | |||
| 607 | /** | ||
| 608 | * @brief The @ref ResultDomain an anchor is declared to resolve, for binding-compatibility gating. | ||
| 609 | * @param anchor The anchor. | ||
| 610 | * @return The domain implied by @ref Anchor::kind: a VtableIdentity is a VtableAddress, a CodeOperand or Manual | ||
| 611 | * a Scalar, a StringXref a CodeSite (a DataAddress for a StringPointerSlot return), an ExportName | ||
| 612 | * provisionally a CodeSite, a RipGlobal a CodeSite only when @ref Anchor::pages narrows it to | ||
| 613 | * executable pages (else a DataAddress), and a Quorum the single specific domain its members agree on | ||
| 614 | * (Unknown when they conflict, or for CallArgHome / Unset). @ref ResolvedAnchor::domain follows the | ||
| 615 | * live page class instead: a code-site kind committed at a non-executable address is stamped | ||
| 616 | * @ref ResultDomain::DataAddress. Allocation-free and side-effect-free. | ||
| 617 | */ | ||
| 618 | [[nodiscard]] ResultDomain declared_domain(const Anchor &anchor) noexcept; | ||
| 619 | |||
| 620 | /** | ||
| 621 | * @brief Maps a @ref ResultDomain to a short human-readable label. | ||
| 622 | * @param domain The domain. | ||
| 623 | * @return A static string view naming the domain. | ||
| 624 | */ | ||
| 625 | [[nodiscard]] std::string_view result_domain_to_string(ResultDomain domain) noexcept; | ||
| 626 | |||
| 627 | /** | ||
| 628 | * @brief Maps a @ref PhysicalSource to a short human-readable label. | ||
| 629 | * @param source The physical source. | ||
| 630 | * @return A static string view naming the source. | ||
| 631 | */ | ||
| 632 | [[nodiscard]] std::string_view physical_source_to_string(PhysicalSource source) noexcept; | ||
| 633 | |||
| 634 | /** | ||
| 635 | * @brief Resolves one anchor with a profile's defaults applied (deny-list, candidate order, broad-string | ||
| 636 | * widen). | ||
| 637 | * @param anchor The anchor to resolve. | ||
| 638 | * @param profile The per-game defaults. A denied backend fails closed; the profile threads into Quorum | ||
| 639 | * sub-anchors, so a denied sub-anchor kind fails the quorum closed. | ||
| 640 | * @param scope The module image to resolve within. | ||
| 641 | * @return A @ref ResolvedAnchor carrying the outcome and (on success) the value. | ||
| 642 | * @note Setup/control-plane only (see @ref resolve). | ||
| 643 | */ | ||
| 644 | [[nodiscard]] ResolvedAnchor | ||
| 645 | resolve_with_profile(const Anchor &anchor, const ScanProfile &profile, Region scope = Region::host()); | ||
| 646 | |||
| 647 | /** | ||
| 648 | * @brief Resolves a table serially with a profile's defaults applied. | ||
| 649 | * @param anchors The anchor table. | ||
| 650 | * @param out The report buffer; at most `min(anchors.size(), out.size())` entries are written. | ||
| 651 | * @param profile The per-game defaults. | ||
| 652 | * @param scope The module image to resolve within. | ||
| 653 | * @return The number of entries written. | ||
| 654 | * @note Setup/control-plane only (see @ref resolve). | ||
| 655 | */ | ||
| 656 | [[nodiscard]] std::size_t resolve_all_with_profile( | ||
| 657 | std::span<const Anchor> anchors, | ||
| 658 | std::span<ResolvedAnchor> out, | ||
| 659 | const ScanProfile &profile, | ||
| 660 | Region scope = Region::host() | ||
| 661 | ); | ||
| 662 | |||
| 663 | /** | ||
| 664 | * @brief Resolves a table concurrently with a profile's defaults applied. | ||
| 665 | * @param anchors The anchor table. | ||
| 666 | * @param out The report buffer; at most `min(anchors.size(), out.size())` entries are written, in input order. | ||
| 667 | * @param profile The per-game defaults. | ||
| 668 | * @param scope The module image to resolve within. | ||
| 669 | * @param max_workers Upper bound on worker threads (0 = auto-select). | ||
| 670 | * @return The number of entries written. | ||
| 671 | * @note Setup/control-plane only: spawns a worker pool. Never call it from a hook or under the loader lock. | ||
| 672 | */ | ||
| 673 | [[nodiscard]] std::size_t resolve_all_with_profile_parallel( | ||
| 674 | std::span<const Anchor> anchors, | ||
| 675 | std::span<ResolvedAnchor> out, | ||
| 676 | const ScanProfile &profile, | ||
| 677 | Region scope = Region::host(), | ||
| 678 | std::size_t max_workers = 0 | ||
| 679 | ); | ||
| 680 | } // namespace anchor | ||
| 681 | } // namespace DetourModKit | ||
| 682 | |||
| 683 | #endif // DETOURMODKIT_ANCHOR_HPP | ||
| 684 |