948 lines
49 KiB
Rust
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)));
|
|
}
|
|
}
|