source2rosetta/src/prototypes.rs
Kamal Tufekcic 3410a79b6a
Some checks failed
CI / fuzz (push) Successful in 2m2s
CI / lint (push) Successful in 15s
CI / test (push) Failing after 18s
ship one record per function: merge the release set, gen reads it, descriptions as doc comments, gates for what was only claimed; v3.0
2026-08-02 22:01:36 +03:00

948 lines
49 KiB
Rust

//! 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<Vec<String>> {
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<String, model::TypeLayout>>,
) -> (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<Vec<String>>,
/// 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<String>,
}
#[derive(Deserialize)]
struct PrototypeDoc {
prototypes: BTreeMap<String, Vec<Decl>>,
/// 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<String>,
}
/// 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<String, model::TypeLayout>>) -> (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<String, model::TypeLayout>>,
) -> (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<String, model::TypeLayout>>,
) -> 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<String>,
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<Candidate<'a>>) {
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<String, model::TypeLayout>>,
vscript_ret: Option<&BTreeMap<String, String>>,
) -> Result<model::AbiManifest> {
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<String, model::AbiEntry> = BTreeMap::new();
let mut counts: BTreeMap<String, usize> = 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<String> = 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<String> = decls
.iter()
.map(|d| d.provenance.clone())
.collect::<BTreeSet<_>>()
.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<Candidate> = 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<Vec<String>> = cands
.iter()
.map(|c| c.params.clone())
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
let mut note = None;
let chosen: Option<Candidate> = 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<Candidate> = 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<String> {
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<String>, complete: bool) -> Candidate<'_> {
Candidate {
params,
complete,
is_const: false,
ret: None,
contract: false,
}
}
fn agrees_t(params: &Vec<String>, 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<float>"), (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)));
}
}