//! Join DECLARED prototypes to the MEASURED register footprint, and judge each one against the binary. //! //! Gamedata says WHERE a function is; it never says what it takes. Types cannot be recovered from a //! stripped binary, so they have to come from a declaration — and a declaration has to be checked before //! anything calls through it, because a stale one produces a call that resolves, passes live validation, //! and then loads the wrong registers. That check is the point of this module: the declared parameter //! list is converted to a SysV register footprint and compared against the footprint `abi` measured in //! the build being shipped. //! //! The declarations are STATIC input (`mappings/prototypes.json`) rather than rolling state — they are //! never folded forward, so unlike the model they live in the repository and need no baseline mechanism. //! What moves per build is the measurement they are judged against. use crate::model; use anyhow::{Context, Result}; use serde::Deserialize; use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; // The provenance ids this module READS are the ones the pipeline STAMPS, imported rather than re-spelled: // they are one fact — which evidence named the function — and two copies of it "kept in step" by a // comment is an invariant nothing enforces. A drift there would silently stop matching, and a prototype // that stops matching does not fail; it simply stops being claimed. use crate::pipeline::{VALVE_CONCOMMAND, VALVE_DATADESC, VALVE_VSCRIPT}; /// What the manifest calls a prototype that came from how the ENGINE invokes the function rather than /// from anyone's declaration of it. Declared here because only this module states it. const ENGINE_CONTRACT: &str = "engine-contract"; /// The prototype the engine invokes EVERY entity-IO handler through. Kept in step with /// `pipeline::IO_HANDLER_INT_ARGS` / `within_io_prototype`, which measure this same claim on every /// derive as a standing oracle — two halves of one fact, one asserting it and one checking it. const ENGINE_CONTRACT_PARAMS: [&str; 2] = ["CEntityInstance*", "InputData_t&"]; /// What the engine passes EVERY console-command callback, whatever form it takes. const CONCOMMAND_PARAMS: [&str; 2] = ["CCommandContext*", "CCommand*"]; /// The engine's dispatch contract for a console command, or `None` if `source` is not one. /// /// The second contract in this module, and it needs the FORM where the entity-IO one needs nothing: a /// `direct` registration passes a plain function, while the two object forms dispatch through a /// receiver, so they take one more integer register. Declaring them all the same way would be wrong in /// whichever direction it erred — the 2-argument list makes every object form a `mismatch`, and the /// 3-argument list is judged as a lower bound, so it would quietly VERIFY a receiver that a direct /// handler does not have and hand a caller a prototype with a bogus leading argument. /// /// Kept in step with `pipeline::concmd_int_args`, which measures this same claim on every derive as a /// standing oracle — two halves of one fact, one asserting it and one checking it. fn concommand_contract(source: &str) -> Option> { let form = source .strip_prefix(VALVE_CONCOMMAND) .and_then(|r| r.strip_prefix(':'))?; let receiver = match form { "direct" => None, // The interface form's receiver is the callback interface itself; the member form's is whatever // object the registering constructor was building, which the binary does not name. `void*` says // "a receiver, type unknown" — the honest claim, and the one `most_specific` already ranks last. "interface" => Some("ICommandCallback*"), "member" => Some("void*"), // A form this build introduced and this code has never measured claims NOTHING. _ => return None, }; Some( receiver .into_iter() .chain(CONCOMMAND_PARAMS) .map(str::to_string) .collect(), ) } /// Read the DIRECTION of a footprint disagreement, and decide what it means. /// /// Split out because it is the whole content of the `mismatch` verdict, and it has to be callable: the /// tests used to re-implement this rule rather than call it, so the regression guard could only fail if /// someone edited both copies the same wrong way. One definition, two callers. /// /// Only an over-READ refutes a declaration. `declared_over` alone is the documented LOWER-BOUND case — /// calling through it loads a register nobody reads, which is safe — while `measured_over` means the /// callee reads a register the declaration never mentions, which is not. `both` stays a mismatch: a /// class where the callee reads more is unsafe regardless of another class where it reads fewer. 81 of /// CS2's 140 former mismatches were the safe direction, reported as "does not describe this build". fn adjudicate_mismatch( chosen: &Candidate, s: &model::AbiShape, types: Option<&BTreeMap>, ) -> (model::AbiStatus, &'static str) { let (i, f) = footprint(chosen.params, types); // The direction has to be read through the SAME allowance the verdict was, or the invisible `this` // reads as an over-count on its own: `CGameEvent::GetFloat` is declared `(char const*, float)` and // measures `int=2 float=0`, where the extra integer register is the receiver and the only real // disagreement is the float. let i = if chosen.complete { i } else { (i..=i + 1) .min_by_key(|d| d.abs_diff(s.int as usize)) .expect("the range always has two elements") }; let (i, f) = (i.min(6), f.min(8)); let measured_over = s.int as usize > i || s.float as usize > f; let declared_over = i > s.int as usize || f > s.float as usize; let status = if declared_over && !measured_over { model::AbiStatus::LowerBound } else { model::AbiStatus::Mismatch }; let note = match (measured_over, declared_over) { (true, true) => { "measured and declared footprints disagree in BOTH directions, in different register \ classes: the callee reads a register the declaration does not mention AND the declaration \ passes one the callee never reads" } (false, true) => { "the declaration passes registers the callee never reads, and contradicts it in no register \ class — the measured footprint is a documented LOWER bound, so this is expected rather than \ evidence against the declaration" } (true, false) => { "measured footprint EXCEEDS declared: the callee reads a register the declaration does not \ mention, so this declaration does not describe this build" } _ => "the footprints disagree in neither direction, which a mismatch cannot be", }; (status, note) } /// One declared prototype as the frozen input records it. #[derive(Deserialize)] struct Decl { /// Absent where the source declared a return type but no parameter list — a `CALL_VIRTUAL(RET, …)` /// site passes VALUES, not types, so it says what comes back and nothing about what goes in. Such a /// declaration contributes a return type and never a signature candidate. #[serde(default)] params: Option>, /// The parameter list is the FULL register-visible argument list, receiver included — a real /// function-pointer type rather than a mangled symbol. Those arities are matched EXACTLY; see /// [`agrees`] for why the alternative has to allow ±1. #[serde(default)] complete: bool, #[serde(rename = "const")] is_const: bool, provenance: String, /// Present only where the source could supply one — Itanium mangling omits return types, so the /// macOS-symbol majority has none. #[serde(default)] ret: Option, } #[derive(Deserialize)] struct PrototypeDoc { prototypes: BTreeMap>, /// Bare method names borne by exactly ONE qualified declaration — computed over the FULL declaration /// set, before pruning. It has to be: pruning removes declarations, so a name borne by dozens /// (`IAppSystem::GetTier`, `Reconnect`, `IsSingleton`) can look unique among what survives, and /// deriving uniqueness from the pruned map would re-open exactly the wrong-class matching the /// bare-name gate exists to prevent. #[serde(default)] bare_unique: BTreeSet, } /// The by-value SysV cost of the few engine math types, for the case where the derived layouts are not /// available (an offline run has no typed schema, so no `types` section). /// /// This is a FALLBACK, not the source of truth. Every entry is reproduced exactly by the derived layouts /// (`Vector` is 12 bytes and SSE, which is two registers), so the two paths agree on everything it /// covers, and the derived path also answers the ~1,960 types it does not. /// /// Measured: on the current declaration set NONE of these six ever reaches here, because every `Vector` /// in a declared prototype is a `Vector const&` or a `Vector*` and the pointer/reference test above /// catches it first. The table is kept anyway — it costs nothing, and a by-value math argument is exactly /// the case whose misclassification manufactured false mismatches in an early pass. const FALLBACK_SSE: &[(&str, usize)] = &[ ("Vector", 2), ("QAngle", 2), ("Vector2D", 1), ("Vector4D", 2), ("Quaternion", 2), ("RadianEuler", 2), ]; /// SysV register cost of ONE declared parameter, as `(integer, float)`. /// /// The classification is not lexical, which is the trap this encodes: a pointer or a reference travels in /// an INTEGER register whatever it points at, while a small all-float aggregate travels in SSE registers — /// `Vector` is 3 floats, so it costs TWO SSE registers by value but ONE integer register by reference. /// Treating `Vector` as integer either way manufactures false mismatches. /// /// Where the deriver's own type layouts are available they decide, because they answer this question for /// EVERY type rather than the handful anyone thought to tabulate: a size settles the memory case, and the /// derived SysV class settles the register case. fn classify(ty: &str, types: Option<&BTreeMap>) -> (usize, usize) { let t = ty.replace("const", ""); let t = t.trim(); if t.contains('*') || t.contains('&') { return (1, 0); } let base = t.split('<').next().unwrap_or(t).trim(); if base == "float" || base == "double" { return (0, 1); } if let Some(l) = types.and_then(|m| m.get(base)) { // Eightbyte count — SysV assigns a register per 8 bytes of an aggregate small enough to travel // in them. let regs = l.size.div_ceil(8); return match l.sysv { model::SysvClass::Sse => (0, regs), model::SysvClass::Integer => (regs, 0), // Above the register budget an argument is copied to the STACK and consumes no register at // all — which the footprint comparison should see as zero, not as one. model::SysvClass::Memory => (0, 0), // Size known, composition not. Fall through to the assumption below rather than inventing a // classification the data does not support. model::SysvClass::Unknown => (1, 0), }; } if let Some((_, n)) = FALLBACK_SSE.iter().find(|(k, _)| *k == base) { return (0, *n); } (1, 0) } /// The declared parameter list's total register footprint. fn footprint( params: &[String], types: Option<&BTreeMap>, ) -> (usize, usize) { params.iter().fold((0, 0), |(i, f), p| { let (a, b) = classify(p, types); (i + a, f + b) }) } /// Does a declared parameter list agree with the footprint measured in the binary? /// /// One allowance is unconditional and is a property of the ABI rather than slack: only six integer /// argument registers exist, so a declared arity above six is compared as `min(n, 6)`. /// /// The second is conditional, and that condition matters. Where the declaration came from a mangled /// symbol, `this` is invisible — a non-static member function and a static one mangle identically — so /// both `n` and `n + 1` have to be accepted, which is why some entries verify "only as static". A /// COMPLETE declaration is a function-pointer type that already names its receiver, so the same allowance /// there is pure slack that hides real staleness: `IScriptVM::CreateVM` is declared with one argument and /// measures two, and `SoundOpGameSystem::StopSoundEvent` is declared with two and measures three. Both /// would pass under `n + 1` while being exactly the case this manifest exists to catch. /// /// The third case inverts the question. The engine's own dispatch contract cannot be stale, so equality /// is the wrong test for it: the measured footprint is a documented LOWER bound (a handler that ignores /// its `InputData_t&` reads one register, a forwarding thunk none), and 49 of CS2's 205 contract-only /// handlers measure fewer than the two the engine always passes. Only an over-count refutes it, which is /// the direction that would mean a caller loads a register the callee never reads. fn agrees( c: &Candidate, sh: &model::AbiShape, types: Option<&BTreeMap>, ) -> bool { let (i, f) = footprint(c.params, types); if c.contract { // A by-value return is the one over-count the register counts cannot show: the caller passes a // hidden output pointer as argument 0 and every other argument shifts, which a `void` contract // says does not happen. `pipeline::within_io_prototype` rejects it for the same reason, so // checking it here keeps the manifest and the standing oracle from ever disagreeing. Empty on // both games today — the oracle reports 715/715 and 624/624 — which is why it is a guard. return sh.ret != "ret=byval" && i.min(6) >= sh.int as usize && f.min(8) >= sh.float as usize; } if f.min(8) != sh.float as usize { return false; } if c.complete { return i.min(6) == sh.int as usize; } [1usize, 0] .iter() .any(|t| (i + t).min(6) == sh.int as usize) } /// One signature the declarations offer, and the convention it is written in. #[derive(Clone, Copy)] struct Candidate<'a> { params: &'a Vec, complete: bool, /// Carried from the declaration that offered this signature, so the chosen one's own const-ness /// travels with it rather than being taken from whichever declaration happened to be listed first. is_const: bool, /// Likewise the return type. Taking it from "the first declaration that has one" would pair the /// ACCEPTED parameter list with a REJECTED declaration's return — two sources describing one /// function, reported as though they were one description. ret: Option<&'a String>, /// This is the ENGINE'S dispatch contract rather than something a source declared, and both of its /// consequences follow from that one fact — it describes how the function is INVOKED, not what /// somebody believed about it. It cannot go stale, so [`agrees`] judges it as a lower bound; and it /// is reported as `matched_by: engine-contract`, so a consumer can tell "the engine calls it this /// way" from "someone wrote this down". contract: bool, } /// Every DISTINCT signature a declaration set offers. Two declarations that write the same parameter /// list in the same convention are one candidate, not an overload. fn collect_candidates<'a>(decls: &'a [Decl], cands: &mut Vec>) { for d in decls { if let Some(p) = d.params.as_ref() && !cands .iter() .any(|c| c.params == p && c.complete == d.complete) { cands.push(Candidate { params: p, complete: d.complete, is_const: d.is_const, ret: d.ret.as_ref(), contract: false, }); } } } /// Pick between signatures that the measurement cannot separate: prefer the one that names its receiver, /// then the one that says the most (`void*` is the least specific thing a declaration can write), then /// alphabetically so the artifact is stable. fn most_specific<'a>(cands: &[Candidate<'a>]) -> Candidate<'a> { *cands .iter() .min_by_key(|c| { ( !c.complete, c.params .iter() .filter(|p| p.replace(' ', "") == "void*") .count(), c.params.clone(), ) }) .expect("caller guarantees a non-empty set") } /// Build the prototype manifest for one build: every shipped function that a declaration names, with the /// verdict its own binary gives that declaration. pub fn build_manifest( prototypes: &Path, mono: &model::Monolith, types: Option<&BTreeMap>, vscript_ret: Option<&BTreeMap>, ) -> Result { let doc: PrototypeDoc = serde_json::from_str( &std::fs::read_to_string(prototypes) .with_context(|| format!("read {}", prototypes.display()))?, ) .context("parse prototypes json")?; // Bare method name -> the qualified names declaring it, among the names present here. Uniqueness is // NOT decided from this map — see `bare_unique`. let mut by_bare: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new(); for name in doc.prototypes.keys() { by_bare .entry(name.rsplit("::").next().unwrap_or(name)) .or_default() .insert(name.as_str()); } // Bare method name -> how many SHIPPED functions bear it. The declaration side alone cannot gate // bare-name matching: uniqueness there says only that one declaration offers the name, never that // one function ANSWERS to it, and 46 bare names are borne by several shipped functions at once. // Without this, a single declaration is handed to every one of them — measured, and it shipped // `CTakeDamageInfo::Constructor` as `verified` taking a `CCSGameRules*`, because both footprints // are one pointer. The name has to be unique on BOTH sides or nothing can say which function the // declaration describes. let mut shipped_bare: BTreeMap<&str, usize> = BTreeMap::new(); for name in mono.core.keys().chain(mono.high_confidence.keys()) { *shipped_bare .entry(name.rsplit("::").next().unwrap_or(name)) .or_default() += 1; } // Measurements come from every tier: an experimental entry's shape is still a fact about the binary. let shapes: BTreeMap<&str, &model::AbiShape> = [&mono.core, &mono.high_confidence, &mono.experimental] .into_iter() .flat_map(|m| m.iter()) .filter_map(|(n, e)| e.abi.as_ref().map(|a| (n.as_str(), a))) .collect(); let mut functions: BTreeMap = BTreeMap::new(); let mut counts: BTreeMap = BTreeMap::new(); let mut bump = |k: &str| *counts.entry(k.to_string()).or_default() += 1; for (tier, section) in [ ("core", &mono.core), ("high_confidence", &mono.high_confidence), ] { for name in section.keys() { let sh = shapes.get(name.as_str()).copied(); let exact = doc.prototypes.get(name); // A bare name is claimed ONLY when exactly one qualified declaration bears it, exactly one // SHIPPED function bears it, and the binary can arbitrate. `SetAbsAngles` exists on many // classes; matching by bare name without a measurement to adjudicate is how an early pass // invented most of its mismatches, and matching without the shipped-side count is how one // declaration gets handed to a dozen unrelated functions. // NOT for a console command: `ConCommand::status` has no C++ method called `status`, so the // tail is a console name that merely looks like one. Matching it would hand an unrelated // declaration to a command handler on a pure spelling coincidence — and the uniqueness gate // cannot catch it, because the command IS the only shipped bearer of that bare name. let bare = name.rsplit("::").next().unwrap_or(name); let by_bare_hit = by_bare .get(bare) .filter(|_| !name.starts_with("ConCommand::")) .filter(|h| { h.len() == 1 && doc.bare_unique.contains(bare) && shipped_bare.get(bare) == Some(&1) && sh.is_some() }) .map(|h| &doc.prototypes[*h.iter().next().unwrap()]); // The ENGINE'S OWN dispatch contract, which is a declaration and a stronger one than any // third-party header: an entity-IO handler is only ever invoked through // `void(CEntityInstance*, InputData_t&)`. It states the WHOLE prototype, and both halves // matter for the same reason — nobody has to have written this function down for it to be // known, because the engine's dispatch settles it. // // The return is where the alternative is weakest: the measured register class is wrong // about known-void functions roughly seven times in eight, because a callee cannot tell // whether its caller reads RAX and scratch use reads back as `ret=int`. The parameters are // where the COVERAGE is: 205 CS2 handlers that no declaration names get a real, checkable // signature instead of a return and a shrug. The deriver already establishes which names // came from the datadesc; this is that fact reaching the manifest. // // Two contracts reach this point now: the entity-IO one above, and the console-command one // (see `concommand_contract`), which the engine states just as firmly and which covers 755 // more CS2 names that no declaration anywhere describes. let src = section .get(name) .and_then(|e| e.provenance.source.as_deref()); let contract_params: Vec = match src { Some(VALVE_DATADESC) => ENGINE_CONTRACT_PARAMS.map(str::to_string).to_vec(), Some(s) => concommand_contract(s).unwrap_or_default(), None => Vec::new(), }; let is_contract = !contract_params.is_empty(); let contract_ret = is_contract.then(|| "void".to_string()); // The slot a vtable-offset locator resolves through, and ONLY when the live oracle // confirmed it (`OffVerdict::Live`). Recorded on every verdict rather than only the ones // that pass the emitters' gate, because it describes the LOCATOR, not the declaration — // and it is the one piece of evidence that settles a receiver the footprint cannot see. // See `model::AbiEntry::vtable` for why validation is part of the condition. let vtable = section .get(name) .filter(|e| e.validated == Some(true)) .and_then(|e| e.locator.offset); let decls: &[Decl] = exact.or(by_bare_hit).map_or(&[][..], |v| v.as_slice()); // The SCRIPT VM'S OWN declared return, for a name the registry states. It ranks above the // measured register class for the reason spelled out below: a callee cannot tell whether its // caller reads RAX, so measurement is wrong about known-void functions roughly seven times // in eight — and `void` is what the registry declares for 849 of Dota's bindings, which is // exactly the population measurement gets wrong. It ranks BELOW a real declaration only to // keep "a source wrote this down" ahead of anything derived; in practice the two never // compete, because no VScript name is also a declared name (measured: zero overlap). // // Read BEFORE the gate below, not after: a registry-declared return is on its own enough to // have something to say about a function, so a name carrying one must not be skipped for // having no parameter list. That is precisely the `return-only` case. let vs_ret = vscript_ret.and_then(|m| m.get(name)).cloned(); let has_vs_ret = vs_ret.is_some(); if decls.is_empty() && !is_contract && !has_vs_ret { bump(&format!("{tier}:none")); continue; } // Any DECLARED return type on offer, used only where the chosen signature carries none of // its own (a return-only declaration has no signature to choose). A source declaration is // preferred over the engine contract only because it is the more specific claim; they // disagree on exactly one function in the current set. The measured register class is the // last resort — a much weaker statement, labelled as such in the artifact's own docs. let any_ret = decls.iter().find_map(|d| d.ret.clone()); let contract = contract_ret.clone(); let fallback_ret = move || { any_ret .clone() .or(contract) .or(vs_ret) .or_else(|| sh.map(|s| s.ret.clone())) }; let mut provenance: Vec = decls .iter() .map(|d| d.provenance.clone()) .collect::>() .into_iter() .collect(); if is_contract { provenance.push(ENGINE_CONTRACT.to_string()); } if has_vs_ret { provenance.push(VALVE_VSCRIPT.to_string()); } // The contract goes in FIRST, so that where it and a declaration both fit the measurement, // `most_specific` reports the one that names its receiver — which the contract always does // and a mangled symbol never can. Nothing is lost by that: `overloads` lists every // signature that was on offer, including the more specific class a source may have named. let mut cands: Vec = Vec::new(); if is_contract { cands.push(Candidate { params: &contract_params, complete: true, is_const: false, ret: None, contract: true, }); } collect_candidates(decls, &mut cands); // `bare-name` is a CLAIM — "one declaration bears this method name and the measurement could // adjudicate" — so it must not be the fallback for an entry that was never name-matched at // all. A registry-declared return with no declaration behind it is neither exact nor // bare-name; it is the script VM stating its own contract, and it says so. let matched_by = if exact.is_some() { "exact" } else if decls.is_empty() && has_vs_ret { VALVE_VSCRIPT } else { "bare-name" }; // Nothing here declares a parameter list — the source said what comes back and stayed silent // about what goes in. There is no arity claim, so there is nothing for the binary to confirm // or refute, and saying "verified" or "mismatch" would claim a check that never happened. // // NOT attempted: reaching for a bare-name declaration's parameter list to fill the gap. It // cannot help, and the reason is structural — the exact declaration is itself a bearer of // that bare name, so the gate's uniqueness test can only pass when the bare-name owner IS // the exact name, which yields these same declarations again. `CBaseEntity::GetEyePosition` // is the case: it stays `return-only` because the only parameter list on offer belongs to // `IBody::GetEyePosition`, a different class. if cands.is_empty() { bump(&format!("{tier}:return-only")); bump(&format!("status:{}", model::AbiStatus::ReturnOnly.as_str())); functions.insert( name.clone(), model::AbiEntry { tier: tier.to_string(), matched_by: matched_by.to_string(), status: model::AbiStatus::ReturnOnly, ret: fallback_ret(), provenance, derived: sh.cloned(), vtable, ..model::AbiEntry::blank() }, ); continue; } // The signatures on offer, deduped by SPELLING: the same list written in both conventions is // one thing a reader has to choose between, not two. let all_sigs: Vec> = cands .iter() .map(|c| c.params.clone()) .collect::>() .into_iter() .collect(); let mut note = None; let chosen: Option = if cands.len() == 1 { Some(cands[0]) } else if let Some(s) = sh { // Signatures the declarations alone cannot separate: let the measurement pick. let fits: Vec = cands .iter() .copied() .filter(|c| agrees(c, s, types)) .collect(); match fits.len() { // NONE of them agrees. That is not an ambiguity — it is the same verdict for every // candidate, so whichever is reported the answer is "no declaration on offer // describes this build", which is precisely what `mismatch` says and what a caller // needs to know. Calling it `ambiguous` would report a doubt that does not exist. 0 => Some(most_specific(&cands)), 1 => { note = Some("overload resolved by measured footprint".to_string()); Some(fits[0]) } // Every survivor agrees with the binary, so the FOOTPRINT is settled and only the type // spellings differ — two sources naming the same argument `void*` and // `CTakeDamageResult*`. Reporting that as `ambiguous` would understate what is known. n => { note = Some(format!( "{n} of {} declarations agree with the measured footprint and differ only \ in the types they name; the most specific of those is reported, and \ `overloads` lists every signature that was on offer, agreeing or not", cands.len() )); Some(most_specific(&fits)) } } } else { None }; // Reachable only with NO measurement and more than one signature on offer: nothing can // separate them, which is the one thing `ambiguous` is for. let Some(chosen) = chosen else { bump(&format!("{tier}:overloaded")); // Counted like every other verdict. Omitting it left `meta.counts` — documented as the // verdict tally — silently missing a status that entries in the file actually carry. bump(&format!("status:{}", model::AbiStatus::Ambiguous.as_str())); functions.insert( name.clone(), model::AbiEntry { tier: tier.to_string(), matched_by: matched_by.to_string(), status: model::AbiStatus::Ambiguous, // The ambiguity is about the PARAMETER list; a declared return type that every // candidate agrees on is not in doubt and is not dropped with them. ret: fallback_ret(), provenance, derived: sh.cloned(), overloads: Some(all_sigs), vtable, ..model::AbiEntry::blank() }, ); continue; }; let mut status = match sh { None => model::AbiStatus::Unverified, Some(s) if agrees(&chosen, s, types) => model::AbiStatus::Verified, Some(_) => model::AbiStatus::Mismatch, }; // A mismatch has two directions and they mean opposite things. Declared ABOVE measured is the // documented lower-bound case — a callee that ignores an argument, or a thunk that reads none // of its own — and calling through it merely loads a register nobody reads. Declared BELOW // measured is the dangerous one: the callee reads an argument the declaration never mentions. // // And a declaration can be wrong in BOTH directions at once, in different register classes, // which an either/or test reports as whichever it happens to check first. `FindUseEntity` is // the case: declared `(CCSPlayer_UseServices*, float)` and measured `int=3 float=0`, so it // passes a float the callee never reads AND leaves two integer registers the callee DOES read // unset. That is the dangerous shape, and it was being described as the harmless one. // Split the disagreement by DIRECTION before reporting it, because the two directions are // not two flavours of the same verdict. Declared-above-measured is the documented // lower-bound case and calling through it loads a register nobody reads; // measured-above-declared leaves a register the callee DOES read unset. 81 of CS2's 140 // former mismatches were the former, reported as "does not describe this build". if status == model::AbiStatus::Mismatch { let s = sh.expect("a mismatch is only reachable with a measurement"); let (verdict, why) = adjudicate_mismatch(&chosen, s, types); status = verdict; note = Some(why.to_string()); } // A BARE-NAME claim that the measurement CONTRADICTS is withdrawn, not reported. The gate // admits a bare name only when a measurement exists to adjudicate it — and adjudicating // means rejecting when the answer is no. `CWorldRendererMgr::LockForRead` takes the empty // parameter list of some other class's `LockForRead` and measures four integer arguments: // that is evidence the JOIN is wrong, not that this function's own declaration went stale, // and reporting `mismatch` would attribute a prototype to a function nothing connects it to. // A LOWER-BOUND disagreement is not a contradiction and is kept. 4 on CS2. if matched_by == "bare-name" && status == model::AbiStatus::Mismatch { bump(&format!("{tier}:none")); bump("bare-name:withdrawn"); continue; } bump(&format!("status:{}", status.as_str())); bump(&format!("{tier}:resolved")); // Where the reported signature IS the contract, say so: "the engine invokes it this way" // and "somebody declared it this way" are different claims and a consumer weighs them // differently. let matched_by = if chosen.contract { ENGINE_CONTRACT } else { matched_by }; functions.insert( name.clone(), model::AbiEntry { tier: tier.to_string(), matched_by: matched_by.to_string(), status, params: Some(chosen.params.clone()), params_complete: chosen.complete.then_some(true), is_const: Some(chosen.is_const), ret: chosen.ret.cloned().or_else(fallback_ret), provenance, derived: sh.cloned(), note, overloads: (cands.len() > 1).then_some(all_sigs), vtable, // Prose belongs to the function record, not to a prototype; the merge attaches it // there and `Rosetta::abi_manifest` joins it back on for the emitters. doc: None, }, ); } } // Counted from the file rather than tallied as entries are built: the other counts are verdicts, // reached once per name, while this one describes entries that four different branches can create. let n_vtable = functions.values().filter(|e| e.vtable.is_some()).count(); if n_vtable > 0 { counts.insert("locator:vtable".to_string(), n_vtable); } Ok(model::AbiManifest { meta: model::AbiMeta { game_key: mono.meta.game_key.clone(), source_build: mono.meta.source_build.clone(), counts, }, functions, }) } #[cfg(test)] mod tests { use super::*; fn shape(int: u8, float: u8) -> model::AbiShape { model::AbiShape { int, float, stack: false, ret: "ret=?".to_string(), } } fn p(v: &[&str]) -> Vec { v.iter().map(|s| (*s).to_string()).collect() } fn classify_t(t: &str) -> (usize, usize) { classify(t, None) } /// A candidate as a mangled symbol writes one: `this` invisible, nothing authoritative. fn cand(params: &Vec, complete: bool) -> Candidate<'_> { Candidate { params, complete, is_const: false, ret: None, contract: false, } } fn agrees_t(params: &Vec, sh: &model::AbiShape) -> bool { agrees(&cand(params, false), sh, None) } #[test] fn sysv_classification_is_not_lexical() { // A `Vector` BY VALUE is 3 floats in two SSE registers… assert_eq!(classify_t("Vector"), (0, 2)); // …but by reference it is one INTEGER register, whatever it points at. Getting this wrong is // what manufactured most of an early pass's false mismatches. assert_eq!(classify_t("Vector const&"), (1, 0)); assert_eq!(classify_t("Vector*"), (1, 0)); assert_eq!(classify_t("float"), (0, 1)); assert_eq!(classify_t("int"), (1, 0)); // A template is classified by its base, not its arguments. assert_eq!(classify_t("CUtlVector"), (1, 0)); } #[test] fn this_is_invisible_in_the_mangling_so_both_arities_are_accepted() { // `void Foo(int)` declares one parameter; as a MEMBER function the call also passes `this`. // The mangling cannot tell the two apart, so a measured 1 and a measured 2 both agree. assert!(agrees_t(&p(&["int"]), &shape(1, 0))); assert!(agrees_t(&p(&["int"]), &shape(2, 0))); assert!(!agrees_t(&p(&["int"]), &shape(3, 0))); } #[test] fn arity_above_the_register_budget_is_compared_capped() { // Only six integer argument registers exist, so a 9-parameter declaration cannot be // distinguished from a 7-parameter one by the footprint alone. let nine = p(&["int"; 9]); assert!(agrees_t(&nine, &shape(6, 0))); } #[test] fn a_complete_declaration_gets_no_this_allowance() { // The ±1 above exists only because a mangled symbol cannot say whether `this` is passed. A // function-pointer type already names its receiver, so allowing it there would let a declaration // that is short by exactly one argument pass — which is `IScriptVM::CreateVM`, declared with one // and measuring two. let one = p(&["IScriptVM*"]); assert!(agrees(&cand(&one, true), &shape(1, 0), None)); assert!(!agrees(&cand(&one, true), &shape(2, 0), None)); assert!(agrees(&cand(&one, false), &shape(2, 0), None)); } #[test] fn the_most_specific_spelling_wins_when_the_binary_cannot_choose() { // Two sources declaring the same function with the same footprint: one says `void*` where the // other names the type. The measurement separates neither, so the informative one is reported. let vague = p(&["CBaseEntity*", "CTakeDamageInfo*", "void*"]); let named = p(&["CBaseEntity*", "CTakeDamageInfo*", "CTakeDamageResult*"]); let cands = [cand(&vague, true), cand(&named, true)]; assert_eq!(most_specific(&cands).params, &named); // A receiver-bearing list outranks one that hides `this`, whatever else it says. let mangled = p(&["CTakeDamageInfo*"]); let cands = [cand(&mangled, false), cand(&vague, true)]; assert_eq!(most_specific(&cands).params, &vague); } #[test] fn the_engine_contract_is_judged_as_a_lower_bound_not_an_equality() { let io = p(&ENGINE_CONTRACT_PARAMS); let contract = Candidate { contract: true, ..cand(&io, true) }; // What the engine passes, exactly: the common case, 156 of CS2's 205. assert!(agrees(&contract, &shape(2, 0), None)); // A handler that ignores its `InputData_t&`, and a forwarding thunk that reads neither // register. Both are real and neither refutes how the engine invokes them — 49 of the 205. assert!(agrees(&contract, &shape(1, 0), None)); assert!(agrees(&contract, &shape(0, 0), None)); // An OVER-count is the one direction that refutes it: the callee reads a register the // dispatch never fills, so either the reader invented an argument or this is not a handler. assert!(!agrees(&contract, &shape(3, 0), None)); assert!(!agrees(&contract, &shape(2, 1), None)); // …and so does an sret return, which the register counts cannot show: it would mean argument 0 // is a hidden output pointer and every other argument sits one register along. let byval = model::AbiShape { ret: "ret=byval".to_string(), ..shape(2, 0) }; assert!(!agrees(&contract, &byval, None)); // The same list from a THIRD PARTY gets no such licence — a declaration can go stale, and // catching that is what the manifest is for. assert!(!agrees(&cand(&io, true), &shape(1, 0), None)); } #[test] fn a_console_command_contract_carries_a_receiver_only_where_the_form_dispatches_through_one() { // The whole point of keying on the form: a direct registration passes a plain function, so its // contract is the two arguments the engine supplies and nothing else. assert_eq!( concommand_contract("valve-concommand:direct").unwrap(), vec!["CCommandContext*", "CCommand*"] ); // The object forms dispatch through a receiver, so they take one more integer register. The // member form's receiver is whatever object the registering constructor was building, which the // binary does not name — `void*` says "a receiver, type unknown" rather than inventing a class. assert_eq!( concommand_contract("valve-concommand:interface").unwrap(), vec!["ICommandCallback*", "CCommandContext*", "CCommand*"] ); assert_eq!( concommand_contract("valve-concommand:member").unwrap(), vec!["void*", "CCommandContext*", "CCommand*"] ); // A form this code has never measured claims NOTHING — it does not fall back to a guess. assert!(concommand_contract("valve-concommand:something-new").is_none()); assert!(concommand_contract("valve-concommand").is_none()); // …and no other provenance is mistaken for one, including the prefix as a bare word. assert!(concommand_contract("valve-datadesc").is_none()); assert!(concommand_contract("catalogue").is_none()); assert!(concommand_contract("valve-concommandering:direct").is_none()); // Judged as a lower bound like the entity-IO contract, and for the same reason: a handler that // ignores its arguments reads fewer registers, and only an OVER-count refutes the dispatch. let direct = p(&["CCommandContext*", "CCommand*"]); let c = Candidate { contract: true, ..cand(&direct, true) }; assert!(agrees(&c, &shape(2, 0), None)); assert!(agrees(&c, &shape(0, 0), None)); // Three integers is what a RECEIVER form measures, and it refutes the direct contract — which // is exactly why the form has to be carried rather than assumed. assert!(!agrees(&c, &shape(3, 0), None)); // A console callback returns void, so a by-value return would mean argument 0 is a hidden // output pointer and every other argument has shifted. let byval = model::AbiShape { ret: "ret=byval".to_string(), ..shape(2, 0) }; assert!(!agrees(&c, &byval, None)); } /// The verdict AND the note, for one declaration against one measurement. /// CALLS the shipped rule rather than restating it. It used to re-implement `build_manifest`'s /// direction logic, which made the assertions below unfalsifiable: only an edit that changed both /// copies the same wrong way could fail them, and that is the one edit nobody makes by accident. /// The direction is read back out of the shipped note text, so the mapping from direction to prose /// is under test too. fn judge(params: &[&str], sh: &model::AbiShape, complete: bool) -> (model::AbiStatus, String) { let ps = p(params); let c = cand(&ps, complete); if agrees(&c, sh, None) { return (model::AbiStatus::Verified, String::new()); } let (status, note) = adjudicate_mismatch(&c, sh, None); let direction = if note.starts_with("measured and declared") { "both" } else if note.starts_with("measured footprint EXCEEDS") { "measured-exceeds" } else if note.starts_with("the declaration passes") { "declared-exceeds" } else { "neither" }; (status, direction.to_string()) } #[test] fn a_mismatch_in_both_directions_is_not_reported_as_the_harmless_one() { // `FindUseEntity`: declared `(CCSPlayer_UseServices*, float)`, measured `int=3 float=0`. It // passes a float the callee never reads AND leaves two integer registers the callee does read // unset. An either/or test finds the float side first and calls the whole thing benign. assert_eq!(judge(&["void*", "float"], &shape(3, 0), true).1, "both"); // The two single-direction cases still read as themselves. assert_eq!(judge(&["void*"], &shape(3, 0), true).1, "measured-exceeds"); assert_eq!( judge(&["void*", "void*", "void*"], &shape(1, 0), true).1, "declared-exceeds" ); // …and only the OVER-read is a mismatch. The declaration that passes a register nobody reads is // consistent with a footprint that is a lower bound, and calling through it is harmless. assert_eq!( judge(&["void*"], &shape(3, 0), true).0, model::AbiStatus::Mismatch ); assert_eq!( judge(&["void*", "float"], &shape(3, 0), true).0, model::AbiStatus::Mismatch ); assert_eq!( judge(&["void*", "void*", "void*"], &shape(1, 0), true).0, model::AbiStatus::LowerBound ); // …and the invisible `this` is not one of them. `CGameEvent::GetFloat` is declared // `(char const*, float)` from a mangled symbol and measures `int=2 float=0`: the extra integer // register IS the receiver, so the only real disagreement is the float the callee never reads. assert_eq!( judge(&["char const*", "float"], &shape(2, 0), false).1, "declared-exceeds" ); } #[test] fn a_float_disagreement_is_decisive() { // The integer side has the `this` allowance; the float side has none, so a declared float // count that differs from the measurement is a real mismatch. assert!(!agrees_t(&p(&["Vector"]), &shape(1, 0))); assert!(agrees_t(&p(&["Vector"]), &shape(1, 2))); } }