read what the binary says about itself: names, signatures, prototypes; gen v2
All checks were successful
CI / lint (push) Successful in 17s
CI / fuzz (push) Successful in 1m52s
CI / test (push) Successful in 24s

This commit is contained in:
Kamal Tufekcic 2026-07-29 20:09:21 +03:00
commit c458b4cb50
34 changed files with 58363 additions and 192 deletions

View file

@ -17,6 +17,10 @@
//! (a rebuild doesn't change which arguments a function takes) and moves precisely when the prototype
//! does — so comparing it across builds flags exactly the prototype changes the byte-sig misses.
//!
//! A `call` is modelled as clobbering every argument register (all 14 are caller-saved), so a value
//! read after one can never be mistaken for an incoming argument — that is what keeps the count a lower
//! bound rather than an occasional over-count.
//!
//! Known limits (all bias toward UNDER-counting = a missed flag, never a false one): a pure forwarding
//! thunk (`jmp Helper`) reads no arg register of its own, so it shapes as `(0,0)`; an argument used
//! only inside a jump-table (indirect-branch) case isn't followed, so it can be missed. Both stay
@ -24,6 +28,10 @@
//! diff's `int==0` low-confidence bucket also absorbs the thunk case. `int_args` is the OBSERVABLE
//! footprint = a lower bound on the declared prototype (a constant-returner reads nothing → `int=0`);
//! that too is stable per function, so the cross-build diff still works.
//!
//! The lower-bound property is MEASURED, not assumed: Valve's entity-IO datadesc declares hundreds of
//! independent handlers to one fixed `void(CEntityInstance*, InputData_t&)` prototype, and every one of
//! them measures within it (see `pipeline::within_io_prototype`). That oracle runs on each derive.
use crate::elf::CodeImage;
use iced_x86::{
@ -36,6 +44,8 @@ use std::collections::HashMap;
/// bitmask over these 14 slots is a function's live-in argument set.
const N_INT: usize = 6;
const N_XMM: usize = 8;
/// All 14 argument slots — the set a call clobbers wholesale (every one is caller-saved).
const ARG_SLOTS: u16 = (1 << (N_INT + N_XMM)) - 1;
/// A function's recovered ABI shape: how many integer/pointer and floating arguments it reads, plus
/// whether it also loads arguments off the stack (a 7th+ integer / 9th+ float argument, or a large
@ -54,8 +64,15 @@ pub struct AbiShape {
/// footprint: a change here (int↔float↔by-value) is a prototype change the arg counts alone miss, and
/// `ByValue` marks the RVO/sret functions that are UNSAFE to blind-call — the caller must pass an
/// output-buffer pointer in RDI, so calling with the object there makes the function WRITE into it
/// (the `CSwapTeams::GetDisplayString` sret trap). Best-effort, with an explicit
/// `Unknown` when the return path doesn't decode — so it only ever adds a signal, never a false one.
/// (the `CSwapTeams::GetDisplayString` sret trap).
///
/// UNLIKE the argument footprint, this is NOT a conservative bound, and it is not evidence about the
/// DECLARED return type. A callee cannot tell whether its caller reads the result register, so a `void`
/// function that merely uses RAX or XMM0 as scratch reads back as `Int`/`Float`: measured against the
/// entity-IO datadesc, whose handlers are all declared `void`, only ~12% classify as [`RetClass::Void`].
/// What it IS good for is the two things it is used for — the `ByValue` blind-call safety flag (no false
/// positive appeared across that same set), and cross-build DIFFING, where the classification is stable
/// per function so a change really does mean the function changed.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Default, Debug)]
pub enum RetClass {
/// No decodable return path (a forwarding thunk / tail call / undecoded) — no signal.
@ -290,6 +307,19 @@ fn insn_effect(factory: &mut InstructionInfoFactory, insn: &Instruction) -> (u16
use_m &= !(1 << slot);
def_m |= 1 << slot;
}
// A CALL clobbers every caller-saved register, and all 14 argument registers are caller-saved —
// only RBX/RBP/R12-R15 survive one. So nothing read AFTER a call can be an incoming argument: the
// value must have been produced since, and anything the callee needed to outlive the call was
// already copied somewhere safe (a read this analysis sees BEFORE the call). Modelling the clobber
// is what keeps the footprint a lower bound; without it a float RETURNED by a callee and used
// afterwards propagates back to the entry as a phantom float argument. Applied after `use_m` is
// computed, so a register the call instruction itself reads (`call rdi`) still counts.
if matches!(
insn.flow_control(),
FlowControl::Call | FlowControl::IndirectCall
) {
def_m = ARG_SLOTS;
}
(use_m, def_m, stack)
}
@ -456,7 +486,16 @@ fn decode_region(img: &CodeImage, entry: u64) -> Option<(Vec<Insn>, bool, bool)>
let next = start + insn.len() as u64;
let mut succ = Vec::new();
match insn.flow_control() {
FlowControl::Return | FlowControl::IndirectBranch => {}
// No successor. `Exception`/`Interrupt` (`ud2`, `int3`) are terminal here for the same reason
// `Return` is: control does not continue to the next instruction, which is inter-function
// padding. Following it would walk into the NEXT function and back-propagate ITS argument
// reads into this one's live-in set — an over-count, the failure direction this module
// promises not to have. Treating a hypothetical resuming `INT n` as terminal can only
// under-count, which is the accepted direction.
FlowControl::Return
| FlowControl::IndirectBranch
| FlowControl::Exception
| FlowControl::Interrupt => {}
FlowControl::UnconditionalBranch => {
let t = insn.near_branch_target();
if in_span(t) {
@ -470,7 +509,9 @@ fn decode_region(img: &CodeImage, entry: u64) -> Option<(Vec<Insn>, bool, bool)>
succ.push(t);
}
}
_ => succ.push(next), // fall-through (incl. call/indirect-call: the call reads no arg regs)
// Fall-through, including a call: control resumes at the next instruction, but the call has
// already killed every argument register in `insn_effect`.
_ => succ.push(next),
}
for &s in &succ {
if !recs.contains_key(&s) {
@ -689,6 +730,40 @@ mod tests {
assert_eq!(shape_of(&[0xF2, 0x0F, 0x51, 0xD9, 0xC3]).key(), (0, 2));
}
// --- a call clobbers every argument register (all 14 are caller-saved) ---
#[test]
fn value_read_after_a_call_is_not_an_argument() {
// call +0 ; movaps xmm1, xmm0 ; ret — XMM0 here holds the CALLEE's float result, not an
// incoming argument. Without the clobber this back-propagates to the entry as a phantom
// float arg, which is how a `void(ptr, ref)` entity-IO handler measured as taking floats.
assert_eq!(
shape_of(&[0xE8, 0x00, 0x00, 0x00, 0x00, 0x0F, 0x28, 0xC8, 0xC3]).key(),
(0, 0)
);
// call +0 ; mov rax, rsi ; ret — same on the integer side.
assert_eq!(
shape_of(&[0xE8, 0x00, 0x00, 0x00, 0x00, 0x48, 0x89, 0xF0, 0xC3]).key(),
(0, 0)
);
}
#[test]
fn a_register_the_call_itself_reads_still_counts() {
// call rdi ; ret — the clobber must not swallow the call instruction's OWN operand read.
assert_eq!(shape_of(&[0xFF, 0xD7, 0xC3]).key(), (1, 0));
}
#[test]
fn a_read_before_the_call_still_counts() {
// mov rbx, rsi ; call +0 ; ret — RSI is copied to a callee-saved register BEFORE the call,
// which is exactly how a real argument survives one, so it is still an argument.
assert_eq!(
shape_of(&[0x48, 0x89, 0xF3, 0xE8, 0x00, 0x00, 0x00, 0x00, 0xC3]).key(),
(2, 0)
);
}
// --- return class ---
#[test]

483
src/concmd.rs Normal file
View file

@ -0,0 +1,483 @@
//! Console commands — the third place a stripped Source-2 module names its own functions, and the only
//! one that is not a table.
//!
//! A `ConCommand` used to be a static object with a vtable, and scanning for that shape is the obvious
//! route. It does not work on CS2: commands are registered through a handle-based `ConCommandRef` whose
//! registry lives in tier0, so `libserver.so` holds no `ConCommand` object and no `_ZTV10ConCommand`
//! relocation to one. Nothing static points at a command name — which is why this was carried for a long
//! time as needing a LIVE process to walk the registry.
//!
//! It does not. The registration is an ordinary call from a static initialiser, and every argument is a
//! constant in the instruction stream:
//!
//! ```text
//! lea rdi, [rip+ref] ; the ConCommandRef this call fills in
//! lea rsi, [rip+"bot_add"] ; the command name
//! lea rdx, [rip+handler] ; the callback
//! mov ecx, 0 ; which FORM the callback takes
//! lea r8, [rip+"bot_add <t|ct> ..."]
//! mov r9d, 0x80004 ; flags
//! call <registrar>
//! ```
//!
//! So the walk is offline: decode each function, track what the argument registers provably hold, and
//! read the vector at every call. The name, the handler, the description and the flags all come from ONE
//! instruction sequence, which is what makes the result checkable — Valve's own command dump states the
//! name and description of each command, and those two arguments agreeing is evidence about the third.
//!
//! Like the table readers this is deliberately shape-driven: the registrar is recognised by what it DOES
//! (it opens by writing the invalid-handle sentinel), a handler is accepted only if it lands in
//! executable code, and a name only if it resolves to a plausible string. A layout change yields
//! FEWER commands, never wrong ones.
use crate::elf::CodeImage;
use iced_x86::{
Decoder, DecoderOptions, FlowControl, Instruction, InstructionInfoFactory, Mnemonic, OpAccess,
OpKind, Register,
};
use std::collections::HashMap;
/// SysV argument registers, by the GPR index [`gpr`] produces.
const RCX: usize = 1;
const RDX: usize = 2;
const RSI: usize = 6;
const RDI: usize = 7;
const R8: usize = 8;
const R9: usize = 9;
/// Caller-saved under SysV: a call destroys any constant we were tracking in these. The `this` a
/// constructor threads through its registrations is callee-saved (rbx, r12-r15), so it survives — which
/// is what makes the member-callback form readable at all.
const CLOBBER: [usize; 9] = [0, RCX, RDX, RSI, RDI, R8, R9, 10, 11];
/// Longest string accepted as a command name. Names are identifiers; anything longer is not one, so the
/// cap doubles as a validity gate.
const MAX_NAME: usize = 64;
/// Bytes of the member-accessor object searched for the handler. The object holds a vtable, the receiver
/// and the member function; the window is scanned for a UNIQUE executable pointer rather than indexed at
/// a fixed slot, so a layout change drops the record instead of quietly moving the answer.
const ACCESSOR_WINDOW: i64 = 0x40;
/// Instruction bytes of a registrar prologue examined for the invalid-handle store.
const PROLOGUE: u64 = 96;
/// How a registration passes its callback. Recorded because it says how much inference produced the
/// address: [`CallbackForm::Direct`] is read straight off the call, the other two resolve one step
/// further.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CallbackForm {
/// The argument IS the handler.
Direct,
/// The argument is a static interface object; the handler is its first virtual.
Interface,
/// The argument is a member of the object under construction; the handler is the executable pointer
/// the enclosing constructor stores into it.
Member,
}
impl CallbackForm {
pub fn describe(self) -> &'static str {
match self {
CallbackForm::Direct => "direct",
CallbackForm::Interface => "interface",
CallbackForm::Member => "member",
}
}
}
/// One registered console command.
#[derive(Clone, Debug)]
pub struct ConsoleCommand {
/// The console-facing name, exactly as Valve compiled it (`bot_add`, `+bugvoice`).
pub name: String,
/// Address of the handler.
pub handler: u64,
/// The raw flags word. Ships raw beside [`flag_names`] so a build that repurposes a bit can be
/// re-read rather than silently mis-labelled.
pub flags: u64,
/// Valve's own help text. Empty when the registration passes none.
pub description: String,
pub form: CallbackForm,
}
/// The flag bits whose meaning is MEASURED, not assumed: each was matched against Valve's published
/// command dump across 742 CS2 commands carrying both a derived flags word and Valve's flag names, and
/// each of these twelve separates that dump exactly — every command with the bit has the name, every
/// command with the name has the bit.
///
/// Three further bits are set in the wild (1, 2 and 33) and are NOT listed, because no name in the dump
/// matches them. Note also that the dump's `developmentonly`, `defensive` and `gamedll` are labels the
/// dumper derives (the first two from the ABSENCE of `release`, the last from which module registered
/// the command) rather than bits — inventing bits for them is the mistake this table exists to avoid.
const FLAG_BITS: [(u32, &str); 12] = [
(0, "linked_concommand"),
(4, "hidden"),
(11, "unlogged"),
(13, "replicated"),
(14, "cheat"),
(17, "dontrecord"),
(19, "release"),
(23, "vconsole_fuzzy_matching"),
(24, "server_can_execute"),
(25, "client_can_execute"),
(27, "vconsole_set_focus"),
(28, "clientcmd_can_execute"),
];
/// The names of the bits set in `flags` that have a measured meaning. Bits without one are omitted here
/// and preserved in [`ConsoleCommand::flags`].
pub fn flag_names(flags: u64) -> Vec<&'static str> {
FLAG_BITS
.iter()
.filter(|(b, _)| flags & (1u64 << b) != 0)
.map(|&(_, n)| n)
.collect()
}
/// Index 0-15 of a GPR, after widening an 8/16/32-bit name to its 64-bit parent.
fn gpr(r: Register) -> Option<u8> {
let f = r.full_register();
f.is_gpr64()
.then(|| (f as usize - Register::RAX as usize) as u8)
}
/// What a register provably holds. `Sym` is an offset from a value we never learned — a constructor's
/// `this` — which is what lets a `lea rdx,[this+0x1c8]` argument be matched against a
/// `mov [this+0x1e8],rax` store made by the same function.
///
/// The epoch is what makes that safe. Stores are collected across the WHOLE function, because the
/// compiler sinks an accessor's handler store past the registration that consumes it. Over that span the
/// base register is eventually reloaded — an epilogue's `pop rbx` alone would otherwise either discard
/// every store or, worse, re-point them at a different object. Bumping a counter instead keeps each run
/// of a register's life distinct, and only same-epoch pairs ever match.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum V {
Unknown,
Const(u64),
Sym(u8, u32, i64),
}
impl V {
fn offset(self, d: i64) -> V {
match self {
V::Const(c) => V::Const(c.wrapping_add(d as u64)),
V::Sym(b, e, dd) => V::Sym(b, e, dd.wrapping_add(d)),
V::Unknown => V::Unknown,
}
}
fn konst(self) -> Option<u64> {
match self {
V::Const(c) => Some(c),
_ => None,
}
}
}
/// Address of a `this`-relative slot: base register, that register's epoch, displacement.
type Slot = (u8, u32, i64);
/// One call to a registrar, with what its argument registers held and the `this`-relative constants its
/// enclosing function stored.
struct Site {
args: [V; 16],
stores: std::sync::Arc<HashMap<Slot, u64>>,
}
/// Does `f` open by storing the invalid-handle sentinel into `*rdi`? That is what a `ConCommandRef`
/// constructor does before it registers, and it identifies the registrar SEMANTICALLY.
///
/// Ranking call targets by how many look like registrations is the tempting alternative and it is wrong
/// twice over: libvscript's top-ranked such target is not the registrar (it yields six confident,
/// entirely fictional commands), and libschemasystem passes an object rather than a function so its
/// registrar never ranks at all.
fn inits_invalid_handle(img: &CodeImage, f: u64) -> bool {
// Saturating: `f` is a decoded near-branch target, so a crafted image can put it at the top of the
// address space and a plain add wraps the range inside out. Found by `fuzz_concmd`.
let Some(code) = img.code_range(f, f.saturating_add(PROLOGUE)) else {
return false;
};
let mut insn = Instruction::default();
let mut dec = Decoder::with_ip(64, code, f, DecoderOptions::NONE);
while dec.can_decode() {
dec.decode_out(&mut insn);
if insn.mnemonic() == Mnemonic::Mov
&& insn.op0_kind() == OpKind::Memory
&& insn.memory_base() == Register::RDI
&& insn.memory_index() == Register::None
&& insn.memory_displacement64() == 0
&& insn.memory_size().size() == 8
&& matches!(
insn.op1_kind(),
OpKind::Immediate32 | OpKind::Immediate32to64 | OpKind::Immediate64
)
&& insn.immediate64() == 0xffff
{
return true;
}
if insn.flow_control() == FlowControl::Return {
break;
}
}
false
}
/// A plausible console-command name: short, printable, no spaces or quoting.
fn cmd_name(img: &CodeImage, va: u64) -> Option<String> {
let s = img.read_c_string(va)?;
let ok = !s.is_empty()
&& s.len() <= MAX_NAME
&& s.bytes()
.all(|c| c.is_ascii_graphic() && c != b'"' && c != b'%');
ok.then_some(s)
}
/// Every console command `img` registers.
pub fn console_commands(img: &CodeImage) -> Vec<ConsoleCommand> {
let mut entries = crate::locate::candidate_entries(img);
entries.extend(img.eh_frame_functions().into_iter().map(|(s, _)| s));
entries.sort_unstable();
entries.dedup();
// Memoised so the prologue test runs once per distinct call target rather than once per call, and so
// only registrar calls are ever materialised as a Site.
let mut is_reg: HashMap<u64, bool> = HashMap::new();
let mut sites: Vec<Site> = Vec::new();
let mut factory = InstructionInfoFactory::new();
for (i, &start) in entries.iter().enumerate() {
let end = entries.get(i + 1).copied().unwrap_or(u64::MAX);
let Some(code) = img.code_range(start, end) else {
continue;
};
// Values are tracked straight-line and reset at each function entry. A branch into the middle of
// a tracked run could carry a stale value forward, which is why every recovered command is
// re-validated against the image: the name string resolves, the handler is executable.
let mut val = [V::Unknown; 16];
let mut epoch = [0u32; 16];
let mut stores: HashMap<Slot, u64> = HashMap::new();
let mut found: Vec<[V; 16]> = Vec::new();
let mut insn = Instruction::default();
let mut dec = Decoder::with_ip(64, code, start, DecoderOptions::NONE);
while dec.can_decode() {
dec.decode_out(&mut insn);
if insn.flow_control() == FlowControl::Call
&& matches!(
insn.op0_kind(),
OpKind::NearBranch16 | OpKind::NearBranch32 | OpKind::NearBranch64
)
{
let t = insn.near_branch_target();
if *is_reg
.entry(t)
.or_insert_with(|| inits_invalid_handle(img, t))
{
found.push(val);
}
for c in CLOBBER {
val[c] = V::Unknown;
}
continue;
}
// A store of a known constant to a `this`-relative slot: how the member form records its
// handler.
if insn.mnemonic() == Mnemonic::Mov
&& insn.op0_kind() == OpKind::Memory
&& insn.memory_index() == Register::None
&& insn.op1_kind() == OpKind::Register
&& let Some(b) = gpr(insn.memory_base())
&& let Some(s) = gpr(insn.op1_register())
&& let Some(v) = val[s as usize].konst()
{
let d = insn.memory_displacement64() as i64;
match val[b as usize] {
V::Unknown => {
stores.insert((b, epoch[b as usize], d), v);
}
V::Sym(bb, e, dd) => {
stores.insert((bb, e, dd.wrapping_add(d)), v);
}
// An absolute address needs no note: it can be read back off the image directly.
V::Const(_) => {}
}
continue;
}
match insn.mnemonic() {
// `lea r,[rip+d]` is a string/global/function address; `lea r,[base+d]` walks to a member.
Mnemonic::Lea => {
if let Some(d) = gpr(insn.op0_register()) {
val[d as usize] = if insn.is_ip_rel_memory_operand() {
V::Const(insn.ip_rel_memory_address())
} else if insn.memory_index() == Register::None {
gpr(insn.memory_base())
.map_or(V::Unknown, |b| match val[b as usize] {
V::Unknown => V::Sym(b, epoch[b as usize], 0),
v => v,
})
.offset(insn.memory_displacement64() as i64)
} else {
V::Unknown
};
}
}
Mnemonic::Mov => {
if let Some(d) = gpr(insn.op0_register()) {
val[d as usize] = match insn.op1_kind() {
OpKind::Immediate8to64
| OpKind::Immediate32to64
| OpKind::Immediate64 => V::Const(insn.immediate64()),
OpKind::Immediate8 | OpKind::Immediate16 | OpKind::Immediate32 => {
V::Const(u64::from(insn.immediate32()))
}
OpKind::Register => {
gpr(insn.op1_register()).map_or(V::Unknown, |s| val[s as usize])
}
_ => V::Unknown,
};
}
}
// the compiler's idiomatic zero
Mnemonic::Xor => {
if let (Some(d), Some(s)) = (gpr(insn.op0_register()), gpr(insn.op1_register()))
{
val[d as usize] = if d == s { V::Const(0) } else { V::Unknown };
}
}
// Anything else: forget only what it WRITES. Invalidating every register OPERAND instead
// ends the epoch of the `this` register on the first `push`/`cmp` against it, which is
// most of a constructor and loses every store it made.
_ => {
for ur in factory.info(&insn).used_registers() {
if matches!(
ur.access(),
OpAccess::Write | OpAccess::ReadWrite | OpAccess::CondWrite
) && let Some(d) = gpr(ur.register())
{
val[d as usize] = V::Unknown;
epoch[d as usize] = epoch[d as usize].saturating_add(1);
}
}
}
}
}
if !found.is_empty() {
let stores = std::sync::Arc::new(stores);
sites.extend(found.into_iter().map(|args| Site {
args,
stores: stores.clone(),
}));
}
}
let mut out: Vec<ConsoleCommand> = Vec::new();
for s in &sites {
let Some(name) = s.args[RSI].konst().and_then(|v| cmd_name(img, v)) else {
continue;
};
// The fourth argument is the callback TYPE, and several of its values (0, 2 and 4 all occur)
// pass a raw function pointer. So accept on what the third argument provably IS rather than on
// a decoded enum: if it lands in executable code, it is the handler. Type 1 alone passes an
// INTERFACE, and that one does need the enum to tell the two indirections apart.
let (handler, form) = match (s.args[RCX], s.args[RDX]) {
(_, V::Const(p)) if img.is_code(p) => (Some(p), CallbackForm::Direct),
(V::Const(1), V::Const(p)) => (
img.read_ptr(p)
.and_then(|vt| img.read_ptr(vt))
.filter(|&f| img.is_code(f)),
CallbackForm::Interface,
),
(V::Const(1), V::Sym(b, e, d)) => {
// The displacement is accumulated with wrapping arithmetic from file-controlled
// values, so walking the window must wrap too rather than overflow.
let mut hits: Vec<u64> = (0..ACCESSOR_WINDOW / 8)
.filter_map(|k| s.stores.get(&(b, e, d.wrapping_add(k * 8))).copied())
.filter(|&v| img.is_code(v))
.collect();
// Sorted before de-duplicating so "exactly one" means one distinct address, not one
// run of adjacent slots — an object may hold the same pointer at two offsets.
hits.sort_unstable();
hits.dedup();
// Exactly one executable pointer in the object, or none is claimed.
((hits.len() == 1).then(|| hits[0]), CallbackForm::Member)
}
_ => (None, CallbackForm::Direct),
};
// Flags are only recorded when they were actually read. Defaulting an untracked r9 to zero
// would ship "no flags" for a command whose flags we merely failed to follow, and a consumer
// cannot tell those apart.
let (Some(handler), Some(flags)) = (handler, s.args[R9].konst()) else {
continue;
};
out.push(ConsoleCommand {
name,
handler,
flags,
description: s.args[R8]
.konst()
.and_then(|v| img.read_c_string(v))
.unwrap_or_default(),
form,
});
}
out.sort_by(|a, b| (&a.name, a.handler).cmp(&(&b.name, b.handler)));
out.dedup_by(|a, b| a.name == b.name && a.handler == b.handler);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_measured_flag_bits_are_named() {
// bot_add ships 0x80004 = bits 2 and 19. Bit 19 is `release`; bit 2 stays unnamed because no
// name in Valve's dump matches it, and naming it anyway is the whole mistake to avoid.
assert_eq!(flag_names(0x80004), vec!["release"]);
// bot_place ships 0x4004 = bits 2 and 14 — bit 14 is `cheat`.
assert_eq!(flag_names(0x4004), vec!["cheat"]);
// A command with no flags names none, rather than falling back to a default.
assert!(flag_names(0).is_empty());
// Every listed bit is distinct and in range.
let mut seen: Vec<u32> = FLAG_BITS.iter().map(|&(b, _)| b).collect();
seen.sort_unstable();
seen.dedup();
assert_eq!(seen.len(), FLAG_BITS.len());
assert!(FLAG_BITS.iter().all(|&(b, _)| b < 64));
}
#[test]
fn symbolic_offsets_keep_the_base_and_track_the_epoch() {
// A `this`-relative walk composes, so `lea rax,[rbx+0x1c8]` then `lea rdx,[rax+0x40]` addresses
// the same object the constructor stored into.
assert_eq!(V::Sym(3, 0, 0x1c8).offset(0x40), V::Sym(3, 0, 0x208));
// A constant walk stays constant.
assert_eq!(V::Const(0x1000).offset(8), V::Const(0x1008));
// Nothing is invented from nothing.
assert_eq!(V::Unknown.offset(8), V::Unknown);
// Two runs of the same register never address each other's slots.
assert_ne!(V::Sym(3, 0, 0x1c8), V::Sym(3, 1, 0x1c8));
// Only a constant is a usable address.
assert_eq!(V::Const(7).konst(), Some(7));
assert_eq!(V::Sym(3, 0, 7).konst(), None);
}
#[test]
fn a_command_name_is_an_identifier_not_prose() {
// The gate is applied to a resolved string, so exercise it through the same predicate the
// reader uses by checking the shape rules it encodes.
let ok = |s: &str| {
!s.is_empty()
&& s.len() <= MAX_NAME
&& s.bytes()
.all(|c| c.is_ascii_graphic() && c != b'"' && c != b'%')
};
assert!(ok("bot_add"));
assert!(ok("+bugvoice")); // an on/off pair is a real command name
assert!(!ok("")); // an empty string is not a name
assert!(!ok("Adds a bot matching the given criteria.")); // a description
assert!(!ok("%s: no varname specified\n")); // a format string
assert!(!ok(&"x".repeat(MAX_NAME + 1)));
}
}

View file

@ -325,6 +325,25 @@ impl CodeImage {
.collect()
}
/// Allocated, initialised, WRITABLE data sections as `(vaddr, byte_len)` — `.data` and
/// `.data.rel.ro`, where a module's static tables live. Returned as ranges rather than slices so
/// callers keep reading through [`read_ptr`](Self::read_ptr) and get the relocated pointer values
/// (a table of function pointers is relocation-driven; its raw file bytes are only incidentally
/// correct).
pub fn data_blocks(&self) -> Vec<(u64, usize)> {
self.secs
.iter()
.filter(|s| {
s.flags & SHF_ALLOC != 0
&& s.flags & SHF_WRITE != 0
&& s.flags & SHF_EXECINSTR == 0
&& s.typ != SHT_NOBITS
&& s.off + s.size <= self.data.len()
})
.map(|s| (s.addr, s.size))
.collect()
}
/// Relocation values that point into executable code — vtable slots and function pointers, i.e.
/// a large set of real function entry addresses obtained without disassembling anything.
pub fn code_pointer_targets(&self) -> Vec<u64> {

View file

@ -30,16 +30,20 @@ pub mod profile;
// ---- low-level engine (implementation detail; `pub` only for the fuzz harness, not a stable surface) ----
pub mod abi;
pub mod concmd;
pub mod elf;
pub mod emit;
pub mod fingerprint;
pub mod live;
pub mod locate;
pub mod par;
pub mod prototypes;
pub mod pulse;
pub mod rtti;
pub mod schema;
pub mod sig;
pub mod taxonomy;
pub mod valvetab;
pub mod xref;
// The canonical model + emitters live in the deriver-free `source2rosetta-core` crate; re-export them so

View file

@ -72,7 +72,7 @@ enum Cmd {
#[arg(long)]
keep: bool,
/// With --gamedata, also run the LIVE fuzzer against this same server for N randomized probes
/// (0 = off). Reuses the launched server — no separate `fuzz-live` run needed for CI.
/// (0 = off). Runs against the server `produce` already launched; there is no separate command for it.
#[arg(long, default_value_t = 500)]
fuzz_iterations: usize,
},
@ -129,6 +129,16 @@ enum Cmd {
/// Multilib non-virtual names to fold as sigs — `{lib: [{name,addr}]}`; `make_sig` runs per lib.
#[arg(long)]
extra_sigs: Option<PathBuf>,
/// Declared C++ prototypes (`mappings/prototypes.json`) to judge against this build's measured
/// register footprints. Emits `abi-<game>.json`. Static repo input — omit to skip the manifest.
#[arg(long)]
prototypes: Option<PathBuf>,
/// Valve's naming for the entity class behind each `PVAL_EHANDLE` Pulse parameter
/// (`mappings/ehandle-classes.json`), propagated across the parameters this build's destructor
/// addresses prove are the same type. Static repo input — omit and the bindings artifact simply
/// states no class.
#[arg(long)]
ehandle_classes: Option<PathBuf>,
/// Byte budget for signatures the FOLD generates (the extrapolated tiers). The derive's own
/// `core` sigs use a separate fixed budget — this flag does not widen those.
#[arg(long, default_value_t = 400)]
@ -356,6 +366,8 @@ fn main() -> Result<()> {
full_names,
extra_offsets,
extra_sigs,
prototypes,
ehandle_classes,
sig_cap,
version,
out_dir,
@ -393,6 +405,8 @@ fn main() -> Result<()> {
full_names: inputs.full_names.as_deref(),
extra_offsets: inputs.extra_offsets.as_deref(),
extra_sigs: inputs.extra_sigs.as_deref(),
prototypes: prototypes.as_deref(),
ehandle_classes: ehandle_classes.as_deref(),
sig_cap,
version: &version,
out_dir: &out_dir,

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
//! CI orchestration + the LIVE half of the engine. `produce` runs the whole per-game build in one
//! long-running command (derive → fold → validate-live → sdk → fold-model), assembling the 3-file monolith
//! long-running command (derive → fold → validate-live → typed netvars → fold-model), assembling the monolith
//! artifact set; `classify-change` and `filter-corpus` are the CI *branch* primitives (is this buildid worth
//! a release? which corpus builds are code-distinct?). Everything that attaches to and drives a RUNNING
//! server lives here, not in `pipeline`: the semantic oracle (`run_live_oracle`, pawn probing, `fuzz_live_run`),
@ -10,9 +10,9 @@ use crate::elf::CodeImage;
use crate::locate::{find_file, load_lib};
use crate::par::{default_threads, parallel_map};
use crate::pipeline::{
ClassScope, CorpusModel, CorpusSource, FoldArgs, GdMap, annotate_validation, build_date,
build_gamedata_cmd, find_builds, fold_model_cmd, gamedata, label_of, lib_filename, load_model,
read_gamedata_str,
ClassScope, CorpusModel, CorpusSource, FoldArgs, Folded, GdMap, annotate_validation,
build_date, build_gamedata_cmd, find_builds, fold_model_cmd, gamedata, label_of, lib_filename,
load_model, read_gamedata_str,
};
use crate::profile::{self, GameProfile};
use crate::sig::Pattern;
@ -100,6 +100,12 @@ pub struct ProduceArgs<'a> {
pub full_names: Option<&'a Path>,
pub extra_offsets: Option<&'a Path>,
pub extra_sigs: Option<&'a Path>,
/// Declared prototypes to JUDGE against this build's measured footprints -> `abi-<game>.json`.
/// Static repo input, not rolling state, so it is passed as a path rather than fetched.
pub prototypes: Option<&'a Path>,
/// Valve's `PVAL_EHANDLE` entity-class naming (`mappings/ehandle-classes.json`) — a static repo
/// input the bindings artifact is enriched with. Optional.
pub ehandle_classes: Option<&'a Path>,
pub sig_cap: usize,
pub version: &'a str,
pub out_dir: &'a Path,
@ -130,6 +136,8 @@ pub fn produce_cmd(a: ProduceArgs) -> Result<()> {
full_names,
extra_offsets,
extra_sigs,
prototypes,
ehandle_classes,
sig_cap,
version,
out_dir,
@ -144,7 +152,7 @@ pub fn produce_cmd(a: ProduceArgs) -> Result<()> {
let p = |name: &str| out_dir.join(name);
let token = prof.token;
// Parse the corpus model ONCE (2.9 GB for Dota): the derive borrows it below, and the sidecar fold in
// Parse the corpus model ONCE (~571 MB for Dota): the derive borrows it below, and the sidecar fold in
// step 4 consumes the same instance — no second parse. A `--corpus` (genesis) run has no model (its model
// is distilled by `corpus-model`); a `--corpus-model` run rolls that model N to N+1 in the fold.
let cmodel: Option<CorpusModel> = match corpus_model {
@ -160,7 +168,11 @@ pub fn produce_cmd(a: ProduceArgs) -> Result<()> {
// 1. derive + fold (offline, in memory) -> the monolith + its CS# render (the string live validate checks)
eprintln!("\n===== derive + fold (offline) =====");
let derived = gamedata(prof, catalogue, source, target)?;
let (mut mono, cssharp) = build_gamedata_cmd(
let Folded {
mut mono,
cssharp,
bindings,
} = build_gamedata_cmd(
prof,
FoldArgs {
build,
@ -170,11 +182,13 @@ pub fn produce_cmd(a: ProduceArgs) -> Result<()> {
core: &derived.core,
flagged: &derived.flagged,
unverified: &derived.unverified,
abi: &derived.abi,
sig_cap,
version,
full_names,
extra_offsets,
extra_sigs,
ehandle_classes,
source_build: &label_of(target),
},
)?;
@ -213,6 +227,15 @@ pub fn produce_cmd(a: ProduceArgs) -> Result<()> {
typed_frac * 100.0,
NETVARS_MIN_TYPED * 100.0
);
// The enum table is read by shape like the class table, so a Valve reshape yields zero
// enums rather than wrong ones — safe, but silent. See GameProfile::min_schema_enums.
ensure!(
nv.meta.enums >= prof.min_schema_enums,
"recovered only {} schema enums (floor {}) — the SchemaSystem enum-binding layout \
likely moved; refusing to ship a schema with its enum vocabulary missing",
nv.meta.enums,
prof.min_schema_enums
);
netvars = Some(nv);
Ok(())
})();
@ -242,6 +265,57 @@ pub fn produce_cmd(a: ProduceArgs) -> Result<()> {
);
artifacts.push(nv_name);
}
// The declared callable surface. Gated PER TABLE, not on the sum: each is matched by its own record
// shape, so Valve reshaping one collapses that one alone — and a summed floor stays satisfied by the
// tables that still work. See GameProfile::min_pulse_bindings.
for (what, got, floor) in [
(
"Pulse bindings",
bindings.meta.pulse,
prof.min_pulse_bindings,
),
(
"typed Pulse signatures",
bindings.meta.pulse_typed,
prof.min_pulse_typed,
),
(
"entity-IO records",
bindings.meta.entity_inputs + bindings.meta.entity_outputs,
prof.min_entity_io,
),
(
"entity classnames",
bindings.meta.entity_classes,
prof.min_entity_classes,
),
(
"console commands",
bindings.meta.commands,
prof.min_commands,
),
] {
ensure!(
got >= floor,
"read only {got} {what} from Valve's in-binary tables (floor {floor}) — that table's layout \
likely moved; refusing to ship a release whose declared surface silently collapsed"
);
}
if !bindings.is_empty() {
let bd_name = format!("bindings-{token}.json");
std::fs::write(p(&bd_name), serde_json::to_string_pretty(&bindings)?)
.with_context(|| format!("write {bd_name}"))?;
eprintln!(
" binding registry -> {bd_name}: {} Pulse bindings ({} typed), {} entity-IO inputs, {} outputs, {} entity classnames, {} console commands",
bindings.meta.pulse,
bindings.meta.pulse_typed,
bindings.meta.entity_inputs,
bindings.meta.entity_outputs,
bindings.meta.entity_classes,
bindings.meta.commands
);
artifacts.push(bd_name);
}
// 4. sidecar: fold model N -> N+1 (offline), emitted when a --corpus-model was the source. The derive has
// returned, so its read-only borrow of the model is done — the fold consumes the same instance by value.
@ -252,6 +326,28 @@ pub fn produce_cmd(a: ProduceArgs) -> Result<()> {
artifacts.push(model_name);
}
// The prototype manifest: the declared parameter types, each judged against the footprint measured
// in THIS build. Emitted beside the gamedata because the two answer different questions — where a
// function is, and how to call it — and a consumer needs both to make a call at all.
if let Some(pp) = prototypes {
let man = crate::prototypes::build_manifest(pp, &mono, netvars.as_ref().map(|n| &n.types))?;
let ab_name = format!("abi-{token}.json");
std::fs::write(p(&ab_name), serde_json::to_string_pretty(&man)?)
.with_context(|| format!("write {ab_name}"))?;
let n = |k: &str| man.meta.counts.get(k).copied().unwrap_or(0);
eprintln!(
" prototype manifest -> {ab_name}: {} entries ({} verified, {} mismatch, {} unverified, \
{} return-only, {} ambiguous)",
man.functions.len(),
n("status:verified"),
n("status:mismatch"),
n("status:unverified"),
n("status:return-only"),
n("core:overloaded") + n("high_confidence:overloaded")
);
artifacts.push(ab_name);
}
// 5. the interop manifest.
let manifest = json!({ "version": version, "artifacts": artifacts });
std::fs::write(p("manifest.json"), serde_json::to_string_pretty(&manifest)?)?;
@ -758,7 +854,7 @@ const ORACLE_MIN_SAMPLE: u32 = 25;
/// wholesale type-record reshape, not on the odd unresolved field.
const NETVARS_MIN_TYPED: f64 = 0.5;
/// The live-fuzzing loop against an ALREADY-ATTACHED server — shared by the standalone `fuzz-live`
/// The live-fuzzing loop against an ALREADY-ATTACHED server — shared by the standalone
/// command and the `integration-test` harness (which owns the server, so no separate launch and no fixed
/// wall-clock: it runs exactly `iterations` probes and stops).
fn fuzz_live_run(
@ -1018,6 +1114,10 @@ pub(crate) fn run_live_oracle(
_ => None,
};
// The derived gamedata, parsed ONCE: the CALL test below needs THIS build's IsPlayerPawn slot, and
// the validate stage needs the whole document.
let doc = gamedata.map(read_gamedata_str).transpose()?;
eprintln!("\n=== read-only oracle on the owned process ===");
let mut verdicts: Vec<(&str, OracleCounts)> = Vec::new();
verdicts.push(("schema-layout", verify_live_cmd(prof, pid, build, lib)?));
@ -1036,12 +1136,37 @@ pub(crate) fn run_live_oracle(
// not `?`-propagate past produce's fail-fast and abort the release. Same treatment as
// `callable_method_sweep` below. `(|| -> Option ...)()` lets one unreadable access bail the probe.
println!("\n=== CALL test (ptrace injection — the thing read-only can't do) ===");
let is_player_pawn = pa.is_player_pawn_slot;
// The slot THIS build derived, not the constant frozen in the profile. The two agree today, but
// the catalogue shows this slot taking four distinct values in nine months, and a stale index
// does not fail loudly — it ptrace-CALLS whatever function now occupies it, on the same live
// process this run then reads typed netvars from and fuzzes 500 times. The frozen value survives
// only as a fallback for a run with no rendered gamedata to consult.
let is_player_pawn = doc
.as_ref()
.and_then(|d| d.get("CBaseEntity::IsPlayerPawn"))
.and_then(|e| render::entry_from_value(e).offset)
.and_then(|o| u64::try_from(o).ok())
.unwrap_or(pa.is_player_pawn_slot);
if is_player_pawn != pa.is_player_pawn_slot {
eprintln!(
" NOTE derived IsPlayerPawn slot {is_player_pawn} differs from the profile's frozen \
{} using the derived one; update GameProfile::is_player_pawn_slot",
pa.is_player_pawn_slot
);
}
let probed = (|| -> Option<()> {
let hp = live.read_i32(pawn + health).ok()?;
println!("alive pawn {pawn:#014x}, live m_iHealth = {hp}");
let vtable_ptr = live.read_u64(pawn).ok()?;
let func = live.read_u64(vtable_ptr + is_player_pawn * 8).ok()?;
// Same gate the other two `call_remote` sites apply: never inject a call to something that
// is not executable code in the live process.
if !live.is_exec(func) {
println!(
" slot {is_player_pawn} does not point at live executable code — skipping"
);
return None;
}
println!(
"calling IsPlayerPawn (gamedata vtable offset {is_player_pawn}, fn {func:#x}) on the live pawn..."
);
@ -1067,11 +1192,11 @@ pub(crate) fn run_live_oracle(
}
}
let live_result = if let Some(gd) = gamedata {
let live_result = if gamedata.is_some() {
println!("\n=== validate-live: derived gamedata vs the running server ===");
// Parse the monolith's CS# render (passed in-memory, no `gamedata.json`) once — it feeds sig/offset
// validation AND the pawn sweep/fuzz below.
let doc = read_gamedata_str(gd)?;
// Parsed once, above — it feeds the CALL test's slot, sig/offset validation, and the pawn
// sweep/fuzz below.
let doc = doc.expect("parsed above whenever `gamedata` is Some");
let (kept, entry_verdicts) = validate_live_cmd(prof, pid, build, &doc)?;
// The semantic sweep + live fuzz operate on a live pawn; pawn-less games stop at sig validation.
if let Some(PawnContext {
@ -1154,17 +1279,23 @@ pub(crate) fn launch_bots_server(
bots: u32,
) -> Result<OwnedServer> {
let bindir = game.join("bin/linuxsteamrt64");
let exe = bindir.join(prof.executable);
ensure!(
exe.exists(),
bindir.join(prof.executable).exists(),
"{} server executable `{}` not found at {}",
prof.display_name,
prof.executable,
exe.display()
bindir.join(prof.executable).display()
);
// ABSOLUTE from here on. `current_dir` below is applied in the CHILD before `exec`, so a relative
// `--game-dir` would have the program path re-resolved from inside `bindir` and fail to spawn —
// after the check above had just found the file, which is the worst shape for a guard to have.
let bindir = bindir
.canonicalize()
.with_context(|| format!("resolve {}", bindir.display()))?;
let exe = bindir.join(prof.executable);
// Live-oracle readiness anchor (via the shared resolve_ready_anchor). A game with
// a player pawn waits for an ALIVE pawn; a pawn-less game (Dota) waits for a live gamerules proxy = map
// loaded + libserver ready, which is all produce's live stages (validate-live + sdk) need.
// loaded + libserver ready, which is all produce's live stages (validate-live + typed netvars) need.
let img = load_lib(build, lib)?;
let (ready_vt, pawn_health) = resolve_ready_anchor(prof, &img)?;
let logpath = std::env::temp_dir().join(format!("{}-produce.log", prof.token));

View file

@ -83,6 +83,29 @@ pub struct GameProfile {
/// from is comparing different objects. A raise is a re-distill, not a config tweak — change it and the
/// model together.
pub max_vtable_slots: usize,
/// Collapse tripwires, ONE PER INDEPENDENTLY-SHAPED TABLE the deriver reads out of the binary rather
/// than deriving. Each is read by its own record shape, so a layout change Valve makes to one yields
/// fewer records from that one alone — safe, but SILENT, and a release shipping zero of any of them at
/// exit 0 is exactly the failure "degrades or stops loudly, never lies" exists to prevent.
///
/// Deliberately NOT one summed floor across all of them: a sum is satisfied by the tables that still
/// work, so it cannot detect the single-table collapse it exists to catch. Set far below the observed
/// count (collapse detectors, not tight bounds); a new game starts every field at 0 and gets no gate
/// until someone measures one.
pub min_pulse_bindings: usize,
/// Bindings whose TYPED SIGNATURE was recovered from their descriptor initializer. Its own floor
/// because it has its own failure mode: the registry can still read perfectly while the descriptor
/// layout moves, and the result would be a release that ships every binding with no signature at all
/// — a silent capability loss rather than a wrong answer, which is precisely what a floor is for.
pub min_pulse_typed: usize,
pub min_entity_io: usize,
pub min_entity_classes: usize,
/// Console commands recovered from their registration calls. Its own floor because it has its own
/// failure mode, and a quiet one: the registrar is identified by SHAPE (it opens by writing the
/// invalid-handle sentinel), so a build that reworks that constructor yields zero commands rather
/// than wrong ones — correct, and invisible without this.
pub min_commands: usize,
pub min_schema_enums: usize,
/// Output game-key the game-keyed emitters use (Metamod `Games { <key> {..} }`, Plugify `{ "<key>": {..} }`).
pub game_key: &'static str,
/// The `--game` CLI token / per-release filename suffix (`cs2`, `dota2`) — distinct from `game_key` (the
@ -108,7 +131,7 @@ pub struct GameProfile {
pub soft_serializer: &'static [&'static str],
/// Method-name prefixes for a this-only blind-callable boolean query — the live call-smoke-test gate.
pub query_prefixes: &'static [&'static str],
/// Live-oracle "famous field" spotlight: per class, the netvars whose live offsets `verify-live` prints
/// Live-oracle "famous field" spotlight: per class, the netvars whose live offsets the oracle prints
/// field-by-field (the ones mods actually read). CS2 gameplay fields on the generic `CBaseEntity`.
pub spotlight_fields: &'static [(&'static str, &'static [&'static str])],
/// Human-readable game name for the shipped gamedata banner.
@ -159,6 +182,13 @@ pub const CS2: GameProfile = GameProfile {
"libvscript.so",
],
max_vtable_slots: 2048,
// observed: 580 Pulse, 715 inputs + 226 outputs, 474 entity classnames, 784 commands, 555 enums
min_pulse_bindings: 300,
min_pulse_typed: 300,
min_entity_io: 400,
min_entity_classes: 200,
min_commands: 400,
min_schema_enums: 250,
game_key: "csgo",
token: "cs2",
executable: "cs2",
@ -255,6 +285,13 @@ pub const DOTA: GameProfile = GameProfile {
"libvscript.so",
],
max_vtable_slots: 2048,
// observed: 500 Pulse, 624 inputs, 3,528 entity classnames, 855 commands, 743 enums
min_pulse_bindings: 250,
min_pulse_typed: 250,
min_entity_io: 300,
min_entity_classes: 1000,
min_commands: 400,
min_schema_enums: 350,
game_key: "dota",
token: "dota2",
executable: "dota2", // bin/linuxsteamrt64/dota2

911
src/prototypes.rs Normal file
View file

@ -0,0 +1,911 @@
//! 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 the deriver stamps on a name it read out of Valve's entity-IO datadesc. Kept in step
/// with `pipeline::VALVE_DATADESC` — the two halves of one fact: which names the datadesc named, and
/// what the engine's dispatch contract therefore says about them.
const VALVE_DATADESC: &str = "valve-datadesc";
/// What the manifest calls a prototype that came from how the ENGINE invokes the function rather than
/// from anyone's declaration of 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&"];
/// The provenance prefix a console-command handler ships under, `:<form>`-suffixed. Kept in step with
/// `pipeline::VALVE_CONCOMMAND`.
const VALVE_CONCOMMAND: &str = "valve-concommand";
/// 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(),
)
}
/// 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>>,
) -> 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());
if decls.is_empty() && !is_contract {
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_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());
}
// 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);
let matched_by = if exact.is_some() {
"exact"
} 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 (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;
// Only an over-read refutes the declaration. `both` stays a mismatch: a class where the
// callee reads more is unsafe regardless of another class where it reads fewer.
if declared_over && !measured_over {
status = model::AbiStatus::LowerBound;
}
note = Some(
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",
}
.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,
},
);
}
}
// 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.
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 (i, f) = footprint(c.params, None);
let i = if complete {
i
} else {
(i..=i + 1)
.min_by_key(|d| d.abs_diff(sh.int as usize))
.unwrap()
};
let (i, f) = (i.min(6), f.min(8));
let over = sh.int as usize > i || sh.float as usize > f;
let under = i > sh.int as usize || f > sh.float as usize;
(
if under && !over {
model::AbiStatus::LowerBound
} else {
model::AbiStatus::Mismatch
},
match (over, under) {
(true, true) => "both",
(true, false) => "measured-exceeds",
_ => "declared-exceeds",
}
.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)));
}
}

616
src/pulse.rs Normal file
View file

@ -0,0 +1,616 @@
//! Pulse binding SIGNATURES — recovered from the accessor's own initializer, offline.
//!
//! [`valvetab`](crate::valvetab) reads the Pulse registry and gets a fully-qualified name, Valve's own
//! documentation strings, a call policy — and two code pointers that are NOT the bound function. This
//! module reads what those two pointers actually are, and they turn out to be the thing the registry was
//! missing: **the typed signature**.
//!
//! Each is an accessor for a function-local `static` holding a `{count, elements}` vector by value, so
//! the fast path is `rax = count | (capacity << 32); rdx = &elements; ret`. `+24` returns the ARGUMENT
//! list and `+32` the RETURN list — measured, not assumed: `CBaseEntityAPI::GetAbsOrigin` yields
//! `_Target: PVAL_EHANDLE` and `retval: PVAL_VEC3_WORLDSPACE`, and `CLightEntityAPI::SetLightColor`
//! yields `_Target: PVAL_EHANDLE, param: PVAL_COLOR_RGB` against an EMPTY return list. Pulse is a typed
//! graph VM, so a binding cannot be registered without this; it is the one prototype source in the
//! project that is neither 2018-era nor transferred from another game.
//!
//! **The elements are built at runtime, so they are zero in the file — but the code that builds them is
//! not.** Every field is written to a fixed RIP-relative address from a `lea` or an immediate, so a
//! constant-propagation pass over the initializer reconstructs the record without a running process.
//! That matters beyond convenience: it makes typed signatures available to an OFFLINE derive, and it
//! avoids calling 580 functions in a live process to read data the binary already states.
//!
//! Shape-driven like the table readers: an element is accepted only when its name pointer resolves to a
//! plausible identifier in non-executable memory AND its type is a value `PulseValueType_t` actually
//! declares. A layout change yields FEWER signatures, never wrong ones, and the profile floor turns
//! "fewer" into a failed release.
use crate::elf::CodeImage;
use iced_x86::{Decoder, DecoderOptions, FlowControl, Instruction, Mnemonic, OpKind, Register};
use std::collections::{BTreeMap, HashMap};
/// Highest `PulseValueType_t` enumerator (`PVAL_COUNT`) plus headroom for a build that adds a few. The
/// enum is schema-registered, so the DERIVED values are what a caller should validate against — this is
/// only the gate that keeps a stale register from being read as a type.
const MAX_PVAL: i64 = 64;
/// Longest plausible parameter name. Names here are C++ parameter identifiers (`_Target`, `pEntity`).
const MAX_NAME: usize = 96;
/// Where the parameter NAME sits inside one element record.
const ELEM_NAME: u64 = 8;
/// Where the element records its type's DESTRUCTOR — a code pointer that is per concrete type rather
/// than per binding, and therefore the only thing in the binary that separates one `PVAL_EHANDLE`'s
/// entity class from another's. Measured: 990 stores across 80 distinct targets on CS2, each target
/// used by exactly one `PulseValueType_t`, and 74 of the 80 are a bare `ret` (the trivially-destructible
/// case). See [`PulseParam::type_token`].
const ELEM_DTOR: u64 = 0x60;
/// How far past the accessor's entry the decoder will follow. These are tiny functions — the largest
/// observed initializer is under 2 KB — so this only bounds a runaway walk into neighbouring code.
const MAX_SPAN: usize = 32 * 1024;
pub use crate::model::PulseParam;
/// A binding's full signature: what it takes and what it gives back.
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct PulseSignature {
pub args: Vec<PulseParam>,
/// Pulse models returns as named out-parameters, so this is a LIST — usually one `retval`, empty for
/// a void binding, and occasionally several.
pub returns: Vec<PulseParam>,
}
/// One call the initializer makes, with whatever the pass could establish about its arguments.
struct Call {
/// The direct target, or `None` for an indirect call.
target: Option<u64>,
rdi: Option<u64>,
rsi: Option<u64>,
rdx: Option<u64>,
}
/// What a constant-propagation pass could establish about the initializer.
#[derive(Default)]
struct Trace {
/// Absolute address -> the pointer or immediate stored there.
writes: BTreeMap<u64, u64>,
/// Call sites, each with the target and its argument registers. A register is present only when it
/// was established since the PREVIOUS call, which is what makes `rsi` the type argument of this call
/// rather than a leftover from earlier in the initializer.
calls: Vec<Call>,
/// `(count, elements)` as the accessor returns them on its already-initialised path.
ret: Option<(u64, u64)>,
}
/// Registers whose value a call destroys. Anything else the pass cannot evaluate is invalidated as the
/// instruction that writes it is seen, so the default is always "unknown" rather than "stale".
const CALLER_SAVED: [Register; 9] = [
Register::RAX,
Register::RCX,
Register::RDX,
Register::RSI,
Register::RDI,
Register::R8,
Register::R9,
Register::R10,
Register::R11,
];
fn full(r: Register) -> Register {
if r.is_gpr() { r.full_register() } else { r }
}
/// Constant-propagate through the accessor, recording every fixed-address store, every call's argument
/// registers, and the vector the fast path returns.
///
/// Deliberately a single ADDRESS-ORDER pass rather than a CFG walk: the guard-protected initializer is
/// straight-line, and a pass that only ever believes values it computed itself cannot invent one. Every
/// instruction it does not model invalidates what it writes.
fn trace(img: &CodeImage, entry: u64, seed_rdi: Option<u64>) -> Option<Trace> {
let code = img.code_at(entry)?;
let cap = code.len().min(MAX_SPAN);
let in_span = |t: u64| t >= entry && ((t - entry) as usize) < cap;
// Reachable instruction addresses, then walked in address order.
let mut seen: HashMap<u64, usize> = HashMap::new();
let mut work = vec![entry];
let mut insn = Instruction::default();
while let Some(at) = work.pop() {
if seen.contains_key(&at) || !in_span(at) || seen.len() > 4000 {
continue;
}
let mut dec =
Decoder::with_ip(64, &code[(at - entry) as usize..], at, DecoderOptions::NONE);
if !dec.can_decode() {
continue;
}
dec.decode_out(&mut insn);
if insn.is_invalid() || insn.len() == 0 {
continue;
}
seen.insert(at, insn.len());
match insn.flow_control() {
FlowControl::Return
| FlowControl::IndirectBranch
| FlowControl::Exception
| FlowControl::Interrupt => {}
FlowControl::UnconditionalBranch => work.push(insn.near_branch_target()),
FlowControl::ConditionalBranch => {
work.push(at + insn.len() as u64);
work.push(insn.near_branch_target());
}
_ => work.push(at + insn.len() as u64),
}
}
let mut addrs: Vec<u64> = seen.keys().copied().collect();
addrs.sort_unstable();
let mut out = Trace::default();
let mut regs: HashMap<Register, u64> = HashMap::new();
// Tracing a callee with its incoming receiver known is what lets an OUTLINED constructor be read:
// the compiler hoists `make an EHANDLE type` into its own function, so the type immediate is inside
// the callee rather than at the call site.
if let Some(v) = seed_rdi {
regs.insert(Register::RDI, v);
}
for at in addrs {
let mut dec =
Decoder::with_ip(64, &code[(at - entry) as usize..], at, DecoderOptions::NONE);
dec.decode_out(&mut insn);
// A store to a fixed address: the only way a field of the static record is written.
if insn.mnemonic() == Mnemonic::Mov
&& insn.op0_kind() == OpKind::Memory
&& insn.is_ip_rel_memory_operand()
{
let dst = insn.ip_rel_memory_address();
let val = match insn.op1_kind() {
OpKind::Register => regs.get(&full(insn.op1_register())).copied(),
OpKind::Immediate32to64 | OpKind::Immediate32 | OpKind::Immediate64 => {
Some(insn.immediate(1))
}
_ => None,
};
if let Some(v) = val {
out.writes.insert(dst, v);
}
}
match insn.flow_control() {
FlowControl::Call | FlowControl::IndirectCall => {
out.calls.push(Call {
target: (insn.flow_control() == FlowControl::Call)
.then(|| insn.near_branch_target()),
rdi: regs.get(&Register::RDI).copied(),
rsi: regs.get(&Register::RSI).copied(),
rdx: regs.get(&Register::RDX).copied(),
});
for r in CALLER_SAVED {
regs.remove(&r);
}
continue;
}
// The already-initialised path returns the vector. The FIRST return reached in address
// order is that path: the guard test falls through to it and jumps away to the builder.
FlowControl::Return => {
if out.ret.is_none() {
// The count must have been ESTABLISHED, never defaulted. Substituting 0 for an RAX
// this pass could not evaluate turns "the accessor was not readable" into an
// affirmative "this binding takes nothing" — a claim, not a gap, and the exact
// failure the project's "degrades or stops loudly, never lies" rule forbids. An
// accessor that genuinely returns an empty vector zeroes RAX with `xor eax, eax`,
// which IS modelled, so honesty here costs no real signature.
out.ret = regs.get(&Register::RAX).map(|&rax| {
(
rax & 0xffff_ffff,
regs.get(&Register::RDX).copied().unwrap_or(0),
)
});
}
continue;
}
_ => {}
}
// Everything below is the modelled arithmetic. An unmodelled write invalidates its destination,
// so a value is only ever believed when this pass computed it.
let dst = if insn.op_count() > 0 && insn.op0_kind() == OpKind::Register {
Some(full(insn.op0_register()))
} else {
None
};
let src = (insn.op_count() > 1 && insn.op1_kind() == OpKind::Register)
.then(|| full(insn.op1_register()));
let imm = matches!(
insn.op1_kind(),
OpKind::Immediate8
| OpKind::Immediate8to32
| OpKind::Immediate8to64
| OpKind::Immediate32
| OpKind::Immediate32to64
| OpKind::Immediate64
)
.then(|| insn.immediate(1));
let Some(d) = dst else { continue };
// Only 32- and 64-bit destinations are modelled. A byte or word write leaves the rest of the
// register alone, so treating it as the register's whole value would invent one — and these
// initializers do write bytes (`mov BYTE PTR [rbp-0x11], 0`). Narrow writes fall through to the
// invalidation below, which is the safe direction.
if insn.op0_register().size() < 4 {
regs.remove(&d);
continue;
}
// A 32-bit write zeroes the upper half, which is exactly how `mov esi, 0xd` reaches RSI.
let mask = if insn.op0_register().size() == 4 {
0xffff_ffff
} else {
u64::MAX
};
let value = match insn.mnemonic() {
Mnemonic::Lea if insn.is_ip_rel_memory_operand() => Some(insn.ip_rel_memory_address()),
// `lea reg, [base + disp]` — how the initializer walks from one element to the next.
Mnemonic::Lea if insn.memory_index() == Register::None => regs
.get(&full(insn.memory_base()))
.map(|b| b.wrapping_add(insn.memory_displacement64())),
Mnemonic::Mov => match (src, imm) {
(Some(s), _) => regs.get(&s).copied(),
(None, Some(i)) => Some(i),
_ => None,
},
// `xor r, r` is the idiomatic zero; a xor of two different registers is not modelled.
Mnemonic::Xor if src == Some(d) => Some(0),
// How the initializer walks from one element to the next when the compiler advances a
// pointer rather than emitting a fresh `lea` — without this, every element after the first
// loses its address and the whole binding drops.
Mnemonic::Add => match (src, imm) {
(Some(s), _) => regs
.get(&d)
.zip(regs.get(&s))
.map(|(a, b)| a.wrapping_add(*b)),
(None, Some(i)) => regs.get(&d).map(|a| a.wrapping_add(i)),
_ => None,
},
Mnemonic::Sub => match (src, imm) {
(Some(s), _) => regs
.get(&d)
.zip(regs.get(&s))
.map(|(a, b)| a.wrapping_sub(*b)),
(None, Some(i)) => regs.get(&d).map(|a| a.wrapping_sub(i)),
_ => None,
},
Mnemonic::And => match (src, imm) {
(Some(s), _) => regs.get(&d).zip(regs.get(&s)).map(|(a, b)| a & b),
(None, Some(i)) => regs.get(&d).map(|a| a & i),
_ => None,
},
Mnemonic::Or => match (src, imm) {
(Some(s), _) => regs.get(&d).zip(regs.get(&s)).map(|(a, b)| a | b),
(None, Some(i)) => regs.get(&d).map(|a| a | i),
_ => None,
},
_ => None,
};
match value {
Some(v) => {
regs.insert(d, v & mask);
}
None => {
regs.remove(&d);
}
}
}
Some(out)
}
/// Is `s` shaped like a C++ parameter name? The gate that separates the record's name slot from every
/// other pointer the initializer stores.
fn is_param_name(s: &str) -> bool {
!s.is_empty()
&& s.len() <= MAX_NAME
&& s.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')
&& s.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'_')
}
/// What one accessor's record region says, before the element stride is known.
struct Record {
t: Trace,
count: u64,
base: u64,
/// Every store of a plausible identifier inside the region. Some are element names; some are not —
/// Dota's `CPulseCursorFuncs::TagCursor` stores an `Ed1` at one element's `+0x28`.
named: BTreeMap<u64, String>,
}
fn record(img: &CodeImage, accessor: u64) -> Option<Record> {
let t = trace(img, accessor, None)?;
let (count, base) = t.ret?;
if count > 32 || (count > 0 && base == 0) {
return None;
}
let named = t
.writes
.iter()
.filter(|&(a, _)| *a >= base)
.filter_map(|(&a, &p)| {
(!img.is_code(p))
.then(|| img.read_c_string(p))
.flatten()
.filter(|s| is_param_name(s))
.map(|s| (a, s))
})
.collect();
Some(Record {
t,
count,
base,
named,
})
}
/// Every CODE pointer an accessor's initializer stores into its record region, with the region base:
/// `(base, [(address written, code address written)])`.
///
/// A DIAGNOSTIC, and deliberately not part of any shipped artifact. The parameter records carry a
/// function pointer whose ROLE is not established — the record reader already has to look at these in
/// order to reject them as parameter names, so exposing them costs nothing and lets that question be
/// settled against evidence collected elsewhere (a runtime call-edge trace) rather than guessed. Nothing
/// here interprets them; they are raw measurements.
pub fn code_stores(img: &CodeImage, accessor: u64) -> Option<(u64, Vec<(u64, u64)>)> {
let r = record(img, accessor)?;
let stores =
r.t.writes
.iter()
.filter(|&(a, _)| *a >= r.base)
.filter(|&(_, p)| img.is_code(*p))
.map(|(&a, &p)| (a, p))
.collect();
Some((r.base, stores))
}
/// The spacings at which this record's `count` names could sit, given that element 0's name is at
/// `base + 8` and the array is contiguous. Usually one; a record carrying a second identifier-shaped
/// string of its own offers more, which is why the stride is settled per IMAGE and not per record.
fn candidate_strides(r: &Record) -> Vec<u64> {
let Some(first) = r.base.checked_add(8) else {
return Vec::new();
};
r.named
.keys()
.filter(|&&a| a > first)
.map(|&a| a - first)
// Checked throughout: a crafted image can place a "name" anywhere, so `k * s` is a value the
// FILE controls and must not be allowed to wrap into a plausible address.
.filter(|&s| {
(0..r.count).all(|k| {
k.checked_mul(s)
.and_then(|o| first.checked_add(o))
.is_some_and(|a| r.named.contains_key(&a))
})
})
.collect()
}
/// Interpret a record at a known stride.
///
/// Returns `None` when it does not hold together — an element short of a name or a type drops the WHOLE
/// list rather than shipping a partial signature, because a signature missing an argument is worse than
/// no signature at all.
fn params_at(img: &CodeImage, r: &Record, stride: u64) -> Option<Vec<PulseParam>> {
// With no stride every element would resolve to element 0, which reads as N copies of the first
// parameter rather than as a failure. A single-element list needs no stride and is unaffected.
if r.count > 1 && stride == 0 {
return None;
}
// `base` is whatever the accessor's RDX constant-propagates to — a value the FILE controls, gated
// only against zero. Every step from it is checked, including this first one: an unchecked `+ 8`
// aborts the overflow-checked build the fuzz harness uses, and `fuzz_pulse` promises fewer
// signatures rather than a panic.
let first = r.base.checked_add(8)?;
(0..r.count)
.map(|k| {
let at = k.checked_mul(stride).and_then(|o| first.checked_add(o))?;
let (ty, type_name) = type_at(img, &r.t, at.checked_add(8)?)?;
Some(PulseParam {
name: r.named.get(&at)?.clone(),
ty,
type_name,
entity_class: None,
// The element's destructor slot. `at` is the NAME, which sits at `ELEM_NAME` into the
// element, so the element base is `at - ELEM_NAME` and the token `ELEM_DTOR` beyond it.
// Absent rather than zero if the initializer never wrote one — a token of 0 would group
// every unwritten parameter together, which is the opposite of what it is for.
type_token: at
.checked_sub(ELEM_NAME)
.and_then(|e| e.checked_add(ELEM_DTOR))
.and_then(|a| r.t.writes.get(&a).copied())
.filter(|&p| img.is_code(p))
.unwrap_or(0),
})
})
.collect()
}
/// Every binding's typed signature, read from the accessor pairs `(args, returns)`.
///
/// The element stride is DERIVED once per image and then applied strictly, rather than re-guessed per
/// record. It has to be: the record size is a property of the type, so one image has one answer, while an
/// individual record can be ambiguous about it — a two-element list plus one stray identifier admits two
/// spacings, and picking the smaller by fiat would read a stray as a parameter name. Taking the value the
/// unambiguous records agree on settles those.
///
/// The winner is the PLURALITY, not a unanimity: requiring every record to agree would let one malformed
/// accessor veto an entire library. That is safe in the direction that matters — a record whose own names
/// do not sit at the winning spacing fails its gate and drops — but it does mean the vote spread is worth
/// looking at, so it is returned and reported rather than reduced to a single number.
///
/// Returns `(signatures, stride, votes, rivals)`, where `rivals` is the number of records that voted for
/// some OTHER spacing. Anything but zero there means the image is not speaking with one voice.
pub fn read_all(
img: &CodeImage,
accessors: &[(u64, u64)],
threads: usize,
) -> (Vec<Option<PulseSignature>>, u64, usize, usize) {
let flat: Vec<u64> = accessors.iter().flat_map(|&(a, b)| [a, b]).collect();
let recs = crate::par::parallel_map(&flat, threads, |&a| record(img, a));
let mut votes: HashMap<u64, usize> = HashMap::new();
for r in recs.iter().flatten().filter(|r| r.count > 1) {
if let [s] = candidate_strides(r).as_slice() {
*votes.entry(*s).or_default() += 1;
}
}
// No record answered unambiguously — a library of single-parameter bindings, or a layout that moved.
// Stride 0 is honest about that: the single-element lists still read, and every longer one fails.
//
// A tie is broken by the SMALLEST stride, and the tie-break is not cosmetic: `max_by_key` over a
// HashMap would otherwise pick by hash order, which makes the emitted artifact depend on iteration
// order rather than on the binary. This project's artifacts are byte-reproducible; a coin flip here
// would quietly end that.
let (stride, won) = votes
.iter()
.max_by_key(|&(s, n)| (*n, std::cmp::Reverse(*s)))
.map_or((0, 0), |(&s, &n)| (s, n));
let rivals: usize = votes
.iter()
.filter(|&(&s, _)| s != stride)
.map(|(_, n)| n)
.sum();
let sigs = accessors
.iter()
.enumerate()
.map(|(i, _)| {
let args = recs[2 * i].as_ref()?;
let returns = recs[2 * i + 1].as_ref()?;
Some(PulseSignature {
args: params_at(img, args, stride)?,
returns: params_at(img, returns, stride)?,
})
})
.collect();
(sigs, stride, won, rivals)
}
/// The `PulseValueType_t` a `CPulseValueFullType` at `obj` is set to, with the schema type it names.
///
/// The type is passed in ESI to a setter called on the object. `rsi` is only believed when it was
/// established since the previous call, which is what distinguishes the setter from the default
/// constructor invoked on the same object first.
///
/// Where the setter is not at this level the compiler has OUTLINED it — `make an EHANDLE type` becomes
/// its own function taking only `this`, so nothing at the call site carries the immediate. Following one
/// level with the receiver seeded recovers it, and 92 of 485 CS2 server bindings need exactly that. Two
/// callees disagreeing drops the parameter rather than picking, since nothing here can adjudicate.
fn type_at(img: &CodeImage, t: &Trace, obj: u64) -> Option<(i32, Option<String>)> {
let read = |c: &Call| {
(
c.rsi.unwrap() as i32,
c.rdx
.filter(|&p| p != 0 && !img.is_code(p))
.and_then(|p| img.read_c_string(p))
.filter(|s| !s.is_empty() && s.len() <= MAX_NAME),
)
};
let direct = t
.calls
.iter()
.filter(|c| c.rdi == Some(obj) && c.rsi.is_some_and(valid_pval))
.map(read)
.next_back();
if direct.is_some() {
return direct;
}
let mut found: Option<(i32, Option<String>)> = None;
for c in t
.calls
.iter()
.filter(|c| c.rdi == Some(obj) && c.rsi.is_none())
{
let Some(inner) = c.target.and_then(|f| trace(img, f, Some(obj))) else {
continue;
};
let Some(hit) = inner
.calls
.iter()
.filter(|c| c.rdi == Some(obj) && c.rsi.is_some_and(valid_pval))
.map(read)
.next_back()
else {
continue;
};
match &found {
Some(prev) if prev.0 != hit.0 => return None,
_ => found = Some(hit),
}
}
found
}
/// A `PulseValueType_t` value, `PVAL_VOID` (-1) included.
fn valid_pval(v: u64) -> bool {
let s = v as i64;
(-1..=MAX_PVAL).contains(&s) || (v as i32) == -1
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_parameter_name_is_an_identifier_and_nothing_else() {
assert!(is_param_name("_Target"));
assert!(is_param_name("retval"));
assert!(is_param_name("pEntity2"));
// The record's other string slots: a description is prose, and a display name has spaces.
assert!(!is_param_name("The entity origin (absolute)."));
assert!(!is_param_name("Get Abs Origin"));
assert!(!is_param_name(""));
assert!(!is_param_name("9lives"));
}
fn rec(count: u64, base: u64, at: &[(u64, &str)]) -> Record {
Record {
t: Trace::default(),
count,
base,
named: at.iter().map(|&(a, n)| (base + a, n.to_string())).collect(),
}
}
#[test]
fn a_stray_string_inside_a_record_offers_a_second_spacing() {
// Dota's `CPulseCursorFuncs::TagCursor`: two parameters at the real stride, plus an identifier
// the record stores for its own reasons. Both 0x70 and 0xC0 place a name at every element, so
// the record ALONE cannot say which is the stride — which is why the answer is taken from the
// image, where the unambiguous records agree.
let r = rec(
2,
0x1000,
&[(8, "pTagName"), (0x78, "tagValue"), (0xc8, "Ed1")],
);
assert_eq!(candidate_strides(&r), vec![0x70, 0xc0]);
// A record with no stray answers on its own, and that is the vote the image counts.
let clean = rec(2, 0x2000, &[(8, "_Target"), (0x78, "param")]);
assert_eq!(candidate_strides(&clean), vec![0x70]);
}
#[test]
fn a_spacing_only_counts_when_every_element_lands_on_it() {
// Three elements, and the run is broken: nothing places a name at all three positions, so the
// record offers no spacing at all rather than a partial one.
let r = rec(3, 0x1000, &[(8, "a"), (0x78, "b"), (0x200, "c")]);
assert!(candidate_strides(&r).is_empty());
}
#[test]
fn pval_void_is_negative_one_and_still_a_type() {
assert!(valid_pval(0)); // PVAL_BOOL
assert!(valid_pval(13)); // PVAL_EHANDLE
assert!(valid_pval(0xffff_ffff)); // PVAL_VOID, as a 32-bit -1
assert!(!valid_pval(0x1000));
}
}

View file

@ -10,7 +10,6 @@
//! `find_vtable` shape (COL at vftable-8, TypeDescriptor `.?AV<name>@@`).
use crate::elf::{CodeImage, KindTag};
use std::collections::HashSet;
pub struct VTable {
pub slot0: u64, // vaddr of virtual slot index 0
@ -236,7 +235,6 @@ fn typeinfo_bases(img: &CodeImage, ti: u64, kinds: &RttiKinds) -> Vec<BaseClass>
pub fn enumerate_vtables(img: &CodeImage, max_slots: usize) -> Vec<ClassVtable> {
let kinds = RttiKinds::detect(img);
let mut out = Vec::new();
let mut seen = HashSet::new();
for (slot, val) in img.reloc_slots() {
if slot < 8 {
continue;
@ -244,10 +242,9 @@ pub fn enumerate_vtables(img: &CodeImage, max_slots: usize) -> Vec<ClassVtable>
let Some((mangled, name)) = typeinfo_name(img, val, &kinds) else {
continue;
};
// No de-dup guard: `reloc_slots` iterates a map KEYED by slot vaddr, so every slot — and hence
// every `slot + 8` — is already unique. A `seen` set here can never reject a candidate.
let vtable_va = slot.wrapping_add(8);
if !seen.insert(vtable_va) {
continue;
}
// offset-to-top sits at vtable-16 (just below the typeinfo field): a plain, non-relocated,
// pointer-aligned int, 0 for a primary table and a small negative for sub-object tables.
let Some(ott) = img.read_i64(slot.wrapping_sub(8)) else {

View file

@ -18,7 +18,7 @@ use crate::elf::CodeImage;
use crate::profile::GameProfile;
use crate::{live, model};
use anyhow::Result;
use std::collections::{BTreeMap, HashSet};
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::path::Path;
/// Byte offsets of the SchemaSystem reflection structs (SchemaClassInfoData_t / SchemaClassFieldData_t /
@ -140,7 +140,6 @@ fn is_type_name(s: &str) -> bool {
/// inventory. Sorted by class name.
pub fn enumerate_schema(img: &CodeImage) -> Vec<SchemaClass> {
let mut out = Vec::new();
let mut seen = HashSet::new();
for (slot, val) in img.reloc_slots() {
if slot < 8 {
continue;
@ -152,10 +151,8 @@ pub fn enumerate_schema(img: &CodeImage) -> Vec<SchemaClass> {
if !is_type_name(&name) {
continue;
}
// No de-dup guard — `reloc_slots` iterates a slot-keyed map, so `slot - 8` is already unique.
let base = slot - 8;
if !seen.insert(base) {
continue;
}
if let Some(cls) = parse_class(img, base, &name, val) {
out.push(cls);
}
@ -164,6 +161,92 @@ pub fn enumerate_schema(img: &CodeImage) -> Vec<SchemaClass> {
out
}
/// One registered Source-2 enum recovered from the schema tables — the semantic vocabulary
/// (`MoveType_t`, `gear_slot_t`, `DamageTypes_t`) that a raw field offset and an integer width cannot
/// supply on their own.
pub struct SchemaEnum {
pub name: String,
/// Underlying integer width in bytes — the binding records it, so a byte-sized enum
/// (`MoveType_t`) is distinguishable from a word-sized one (`gear_slot_t`) without inference.
pub size: u8,
pub align: u8,
/// Enumerators in DECLARATION order (names are unique; values are not — aliases like
/// `MOVETYPE_LAST` / `MOVETYPE_INVALID` legitimately share one).
pub values: Vec<(String, i64)>,
}
// A `CSchemaEnumBinding`, relative to the slot holding its name pointer.
const EB_TYPE_NAME: u64 = 0; // char* — the enum's type name (the reloc slot this is found by)
const EB_WIDTH: u64 = 16; // u8 size, u8 alignment, u16 flags, u32 enumerator count
const EB_VALUES: u64 = 24; // -> the enumerator array
const EV_STRIDE: u64 = 32; // one enumerator: char* name, i64 value, then metadata
const EV_VALUE: u64 = 8;
/// Enumerator-count sanity bound. The largest real CS2 enum is ~100 values; this only has to reject a
/// field that isn't a count at all before it drives an allocation.
const EB_MAX_VALUES: u32 = 4096;
/// Enumerate every registered enum in `img`, alongside [`enumerate_schema`]'s classes. Same reloc-driven
/// discovery: an enum binding is found by the slot holding its type-name pointer, then accepted only if
/// the width/count word and the enumerator array both read as what they claim to be — so a layout change
/// yields fewer enums, never wrong ones. Sorted by name.
pub fn enumerate_enums(img: &CodeImage) -> Vec<SchemaEnum> {
let mut out = Vec::new();
for (slot, val) in img.reloc_slots() {
let Some(name) = img.read_c_string(val) else {
continue;
};
// `reloc_slots` iterates a slot-keyed map, so a per-slot de-dup set can reject nothing; the real
// de-dup is by NAME, at the sort/dedup below (a shared enum is registered by several libraries).
if !is_type_name(&name) {
continue;
}
let base = slot.wrapping_sub(EB_TYPE_NAME);
let Some(w) = img.read_ptr(base.wrapping_add(EB_WIDTH)) else {
continue;
};
let (size, align, count) = (w as u8, (w >> 8) as u8, (w >> 32) as u32);
if !matches!(size, 1 | 2 | 4 | 8)
|| !matches!(align, 1 | 2 | 4 | 8)
|| count == 0
|| count > EB_MAX_VALUES
{
continue;
}
let Some(arr) = img
.read_ptr(base.wrapping_add(EB_VALUES))
.filter(|&a| a != 0)
else {
continue;
};
// Every enumerator must read cleanly; a partial read means this was not an enum binding.
let mut values = Vec::with_capacity(count as usize);
for i in 0..u64::from(count) {
let rec = arr.wrapping_add(i.wrapping_mul(EV_STRIDE));
let (Some(n), Some(v)) = (
img.read_ptr(rec).and_then(|p| img.read_c_string(p)),
img.read_i64(rec.wrapping_add(EV_VALUE)),
) else {
break;
};
if n.is_empty() {
break;
}
values.push((n, v));
}
if values.len() == count as usize {
out.push(SchemaEnum {
name,
size,
align,
values,
});
}
}
out.sort_by(|a, b| a.name.cmp(&b.name));
out.dedup_by(|a, b| a.name == b.name); // one binding per name; libs re-register shared enums
out
}
fn parse_class(img: &CodeImage, base: u64, name: &str, name_ptr: u64) -> Option<SchemaClass> {
let size = img.read_i32(base.wrapping_add(CI_SIZE))?;
if size <= 0 || size >= (1 << 23) {
@ -274,6 +357,12 @@ pub(crate) fn live_schema(
let mut classes: BTreeMap<String, BTreeMap<String, Field>> = BTreeMap::new();
let (mut typed, mut untyped) = (0usize, 0usize);
let mut seen: HashSet<String> = HashSet::new();
let mut enums: BTreeMap<String, model::EnumDef> = BTreeMap::new();
// The schema states each registered class's instance size — the exact half of the layout picture.
let mut registered_sizes: BTreeMap<String, usize> = BTreeMap::new();
// The base graph the schema already recovers — exported, so a consumer can resolve an inherited
// field, and consulted here so the SysV verdict sees inherited members.
let mut bases: BTreeMap<String, Vec<model::BaseClass>> = BTreeMap::new();
let mut nlibs = 0usize;
for &lib in prof.libs {
let Ok(img) = crate::locate::load_lib(dir, lib) else {
@ -281,6 +370,18 @@ pub(crate) fn live_schema(
};
let Some(base) = live.base(lib) else { continue }; // lib not mapped in the process -> skip
nlibs += 1;
// Enum bindings are static, so they come from the IMAGE — no process read, unlike field types.
// First library wins, matching the class precedence: a shared enum has one definition.
for e in enumerate_enums(&img) {
enums.entry(e.name).or_insert_with(|| model::EnumDef {
size: e.size,
values: e
.values
.into_iter()
.map(|(name, value)| model::EnumValue { name, value })
.collect(),
});
}
for c in &enumerate_schema(&img) {
// a shared class already taken from an earlier (higher-precedence) lib — identical layout, skip
if !seen.insert(c.name.clone()) {
@ -334,12 +435,54 @@ pub(crate) fn live_schema(
},
);
}
if let Ok(sz) = usize::try_from(c.size) {
registered_sizes.insert(c.name.clone(), sz);
}
if !c.bases.is_empty() {
bases.insert(
c.name.clone(),
c.bases
.iter()
.map(|b| model::BaseClass {
name: b.name.clone(),
offset: b.offset,
})
.collect(),
);
}
classes.insert(c.name.clone(), fmap);
}
}
let (types, cal) = derive_type_layouts(&classes, &registered_sizes, &bases);
// A size is only useful to a caller once it reaches the FIELD: `size` was zero for every aggregate,
// which reads as "unknown" and is exactly what the layout pass now answers.
for fields in classes.values_mut() {
for f in fields.values_mut() {
if f.size == 0
&& let Some(t) = base_type(&f.ty).and_then(|b| types.get(b))
{
// The field's EXTENT, so a consumer can bound a read: a fixed array spans
// element x count. Writing the element size here would understate `char[128]` as 1.
f.size = t.size.saturating_mul(array_len(&f.ty));
}
}
}
let derived_sizes = classes
.values()
.flat_map(|c| c.values())
.filter(|f| f.size > 0)
.count();
eprintln!(
"typed netvars: {} classes across {nlibs} libs, {typed} typed fields, {untyped} unresolved",
classes.len()
"typed netvars: {} classes across {nlibs} libs, {typed} typed fields, {untyped} unresolved; \
{} enums / {} enumerators; {} type layouts ({derived_sizes}/{} fields sized, \
field-gap calibration {}/{} exact)",
classes.len(),
enums.len(),
enums.values().map(|e| e.values.len()).sum::<usize>(),
types.len(),
typed + untyped,
cal.exact,
cal.checked
);
Ok(Schema {
meta: SchemaMeta {
@ -347,7 +490,267 @@ pub(crate) fn live_schema(
source_build: source_build.to_string(),
typed,
untyped,
enums: enums.len(),
types: types.len(),
},
classes,
bases,
enums,
types,
})
}
// ══════════════════════════════════════════════════════════════════════════════════════════════
// Type layouts — what a caller needs to PASS a value, which an offset alone cannot supply
// ══════════════════════════════════════════════════════════════════════════════════════════════
/// SysV classification for the engine value types the SchemaSystem does NOT register, and whose size
/// alone cannot settle how they travel.
///
/// At 16 bytes or less an aggregate goes in SSE registers when every member is floating-point and in
/// integer registers otherwise, and no derived size distinguishes those two. Above 16 bytes the size
/// settles it, so nothing needs declaring. This is therefore the ONE place the deriver declares rather
/// than derives, deliberately kept to a closed set of engine primitives — each entry is what the type
/// demonstrably IS, not a guess: the math types are plain float aggregates, and everything else here is
/// a pointer, a handle or a packed integer.
const UNREGISTERED_CLASSES: &[(&str, model::SysvClass)] = &[
// All-float aggregates — SSE. `Vector` by value costs TWO SSE registers; by reference, one integer.
("Vector", model::SysvClass::Sse),
("VectorWS", model::SysvClass::Sse),
("Vector2D", model::SysvClass::Sse),
("Vector4D", model::SysvClass::Sse),
("QAngle", model::SysvClass::Sse),
("Quaternion", model::SysvClass::Sse),
("RadianEuler", model::SysvClass::Sse),
("QuaternionStorage", model::SysvClass::Sse),
// Pointers, handles and packed integers — INTEGER.
("CUtlString", model::SysvClass::Integer),
("CUtlSymbolLarge", model::SysvClass::Integer),
("CUtlSymbol", model::SysvClass::Integer),
("CGlobalSymbol", model::SysvClass::Integer),
("CUtlStringToken", model::SysvClass::Integer),
("CHandle", model::SysvClass::Integer),
("CEntityHandle", model::SysvClass::Integer),
("CStrongHandle", model::SysvClass::Integer),
("CWeakHandle", model::SysvClass::Integer),
("CGameSoundEventName", model::SysvClass::Integer),
("Color", model::SysvClass::Integer),
("CTransform", model::SysvClass::Memory), // 32 bytes; stated for clarity, size settles it anyway
];
/// The SysV boundary: an aggregate above this is passed in memory, so its size settles its class.
const SYSV_REGISTER_LIMIT: usize = 16;
/// A field-gap size is accepted only with this much agreement across observations — the modal gap has to
/// dominate, or the "next field" is padding/union noise rather than this field's extent.
const GAP_AGREEMENT: f64 = 0.8;
/// …and only with at least this many observations, so one lucky class cannot mint a size.
const GAP_MIN_OBS: usize = 4;
/// A builtin's SysV class. The floating types travel in SSE registers, every other builtin in integer
/// ones — a property of the ABI, not of Valve's code, which is why it is stated here rather than derived.
fn builtin_sysv(t: &str) -> Option<model::SysvClass> {
match t {
"float32" | "float64" | "double" => Some(model::SysvClass::Sse),
"int8" | "uint8" | "char" | "bool" | "int16" | "uint16" | "int32" | "uint32" | "int64"
| "uint64" => Some(model::SysvClass::Integer),
_ => None,
}
}
/// How well the field-gap inference reproduced the sizes that are known exactly — the same free-oracle
/// idea as the entity-IO ABI check: the builtins have an independently known size, so running the
/// inference over them and comparing is a per-build test of the inference itself, not an assumption.
pub struct GapCalibration {
pub checked: usize,
pub exact: usize,
}
/// The bare type name behind a field's declared type: array suffix stripped, template arguments dropped.
/// `None` for a pointer (its size is the pointer's, and it says nothing about the pointee) or a bitfield.
fn base_type(ty: &str) -> Option<&str> {
let t = ty.trim().split('[').next()?.trim();
if t.ends_with('*') || t.starts_with("bitfield") || t.is_empty() {
return None;
}
Some(t.split('<').next()?.trim())
}
/// Every type NAME a declared field type mentions: the outer type plus each template argument, since an
/// inner type is a real type a consumer must know — `CUtlLeanVector<CPulseRuntimeMethodArg>` is how the
/// element type of a Pulse method's argument list is spelled, and stripping the template arguments loses it.
fn mentioned_types(ty: &str) -> Vec<&str> {
let mut out = Vec::new();
if let Some(b) = base_type(ty) {
out.push(b);
}
// Template arguments, comma-split at depth 1 so a nested template stays with its parent.
if let Some(open) = ty.find('<') {
let inner = &ty[open + 1..ty.rfind('>').unwrap_or(ty.len())];
let (mut depth, mut start) = (0usize, 0usize);
for (i, c) in inner.char_indices() {
match c {
'<' | '(' | '[' => depth += 1,
'>' | ')' | ']' => depth = depth.saturating_sub(1),
',' if depth == 0 => {
out.extend(mentioned_types(&inner[start..i]));
start = i + 1;
}
_ => {}
}
}
out.extend(mentioned_types(&inner[start..]));
}
out
}
/// The declared array length of a field type (`float32[3]` -> 3), else 1.
fn array_len(ty: &str) -> usize {
ty.rsplit_once('[')
.and_then(|(_, n)| n.strip_suffix(']'))
.and_then(|n| n.trim().parse::<usize>().ok())
.filter(|&n| n > 0)
.unwrap_or(1)
}
/// Is every member of `ty`, inherited members included, a floating-point value? `None` when the answer
/// cannot be established — an unknown base, or a member whose own type is not resolvable — because
/// "unknown" and "not all float" are different answers and only one of them is safe to act on.
fn all_float(
ty: &str,
classes: &BTreeMap<String, BTreeMap<String, model::Field>>,
bases: &BTreeMap<String, Vec<model::BaseClass>>,
depth: usize,
) -> Option<bool> {
if depth > 8 {
return None; // pathological or cyclic hierarchy — decline rather than guess
}
let fields = classes.get(ty)?;
for b in bases.get(ty).map(Vec::as_slice).unwrap_or_default() {
if !all_float(&b.name, classes, bases, depth + 1)? {
return Some(false);
}
}
// A class with no members of its own and no bases tells us nothing about how it travels.
if fields.is_empty() && bases.get(ty).is_none_or(Vec::is_empty) {
return None;
}
Some(
fields
.values()
.all(|f| matches!(base_type(&f.ty), Some("float32" | "float64"))),
)
}
/// Recover a size and a SysV class for every type the schema's fields refer to.
///
/// Two independent routes, and which one produced a given answer is recorded rather than blurred:
/// a REGISTERED class states its own instance size, and everything else is inferred from the distance to
/// the next field — schema fields are laid out in offset order, so that gap IS the field's extent. The
/// inference is calibrated on the types whose size is independently known: every primitive
/// (`float32`, `int64`, …) comes back exact.
pub fn derive_type_layouts(
classes: &BTreeMap<String, BTreeMap<String, model::Field>>,
registered_sizes: &BTreeMap<String, usize>,
bases: &BTreeMap<String, Vec<model::BaseClass>>,
) -> (BTreeMap<String, model::TypeLayout>, GapCalibration) {
// base type -> observed per-element gap -> how many times it was seen
let mut gaps: BTreeMap<&str, BTreeMap<usize, usize>> = BTreeMap::new();
for fields in classes.values() {
let mut by_off: Vec<(&model::Field, &str)> =
fields.values().map(|f| (f, f.ty.as_str())).collect();
by_off.sort_by_key(|(f, _)| f.offset);
for w in by_off.windows(2) {
let (f, ty) = w[0];
let gap = w[1].0.offset - f.offset;
// A non-positive gap is a union or an overlapping bitfield, not an extent.
let (Some(base), true) = (base_type(ty), gap > 0) else {
continue;
};
let n = array_len(ty);
if gap as usize % n != 0 {
continue; // the gap does not divide into the declared element count — not this field's
}
*gaps
.entry(base)
.or_default()
.entry(gap as usize / n)
.or_default() += 1;
}
}
let declared: BTreeMap<&str, model::SysvClass> = UNREGISTERED_CLASSES.iter().copied().collect();
let mut out = BTreeMap::new();
// Every type any field refers to — a type used only behind a pointer still deserves an entry when
// its size is known from the schema.
let mut wanted: BTreeSet<&str> = BTreeSet::new();
for fields in classes.values() {
for f in fields.values() {
wanted.extend(mentioned_types(&f.ty));
}
}
wanted.extend(gaps.keys().copied());
// Every registered class, whether or not any field happens to name it — the schema states its size, so
// withholding the entry would be losing an answer we already hold.
wanted.extend(registered_sizes.keys().map(String::as_str));
let mut cal = GapCalibration {
checked: 0,
exact: 0,
};
for ty in wanted {
let builtin = builtin_size(ty);
// Where a size is known exactly, CHECK the inference against it rather than using the inference.
if builtin > 0
&& let Some(hist) = gaps.get(ty)
&& let Some((&sz, _)) = hist.iter().max_by_key(|&(_, n)| *n)
{
cal.checked += 1;
cal.exact += usize::from(sz == builtin as usize);
}
let (size, source, obs, agree) = match (builtin, registered_sizes.get(ty)) {
// A builtin's size is fixed by the ABI.
(b, _) if b > 0 => (b as usize, model::LayoutSource::Declared, None, None),
// The schema states a registered class's size — no inference needed.
(_, Some(&sz)) => (sz, model::LayoutSource::Schema, None, None),
_ => {
let Some(hist) = gaps.get(ty) else { continue };
let total: usize = hist.values().sum();
let (&sz, &n) = hist.iter().max_by_key(|&(_, n)| *n).expect("non-empty");
if total < GAP_MIN_OBS || (n as f64) < GAP_AGREEMENT * total as f64 {
continue; // too thin or too contested to state a size
}
(sz, model::LayoutSource::FieldGap, Some(total), Some(n))
}
};
// Above the register limit the size decides. At or below it, the question is whether every member
// is floating-point — derivable for a registered class by inspecting its fields, and declared for
// the closed set of primitives the schema omits.
let sysv = if let Some(c) = builtin_sysv(ty) {
c
} else if size > SYSV_REGISTER_LIMIT {
model::SysvClass::Memory
} else if let Some(&c) = declared.get(ty) {
c
} else {
// SSE requires that EVERY member is floating-point — including inherited ones. Judging on
// a class's own fields alone calls a type SSE whose base contributes the first eightbyte
// (a pointer), which is the difference between passing it in XMM0 and in RDI.
match all_float(ty, classes, bases, 0) {
Some(true) => model::SysvClass::Sse,
Some(false) => model::SysvClass::Integer,
None => model::SysvClass::Unknown,
}
};
out.insert(
ty.to_string(),
model::TypeLayout {
size,
sysv,
source,
observations: obs,
agreement: agree,
},
);
}
(out, cal)
}

544
src/valvetab.rs Normal file
View file

@ -0,0 +1,544 @@
//! Valve's in-binary NAME tables — the one place a stripped Source-2 module names its own functions.
//!
//! Two static tables sit in writable data, and they give different things:
//!
//! - **Entity-IO datadesc** (112-byte stride, in the game library) pairs a NAME with the FUNCTION: the
//! C++ handler name (`InputKill`), the map-facing input name (`Kill`), and the handler's address.
//! - **Pulse bindings** (80-byte stride, in every module registering Pulse cells or an entity API)
//! pairs a fully-qualified `Class::Method` with the author-facing display name and description, two
//! metadata words — and two code pointers that are DESCRIPTOR ACCESSORS, not the bound function
//! (see [`names`]). It documents the callable surface; it does not locate it.
//!
//! Both are ground truth from the shipped binary — the names are what Valve compiled in, not a transfer
//! from another game and not an inference — so they outrank every other naming source, and extracting
//! them belongs beside the RTTI and schema readers rather than in a side-channel: the source travels
//! with the binary, so it re-derives on every build for free and needs no genesis input.
//!
//! Both readers are deliberately shape-driven, not offset-driven: a record is accepted only when its
//! name pointer resolves to a plausibly-shaped string AND its function pointer lands in executable
//! code. A layout change therefore yields FEWER records, never wrong ones.
use crate::elf::CodeImage;
use std::collections::HashMap;
/// Bytes of one Pulse binding record. The scan steps 8 bytes rather than by stride (a table's first
/// record is not aligned to any section-relative boundary), so this only bounds the read.
const PULSE_STRIDE: usize = 80;
/// Bytes of one entity-IO datadesc record — a `typedescription_t`. The array interleaves plain field
/// descriptors, inputs and outputs at this one stride; the `Input` name prefix selects the inputs.
const DATADESC_STRIDE: usize = 112;
/// Longest string either table is expected to hold. Descriptions are prose, names are identifiers; a
/// pointer that resolves to anything longer is not a record field, so the cap doubles as a validity gate.
const MAX_STR: usize = 256;
/// One Pulse scripting binding, as the module registers it.
#[derive(Clone, Debug)]
pub struct PulseBinding {
/// Fully-qualified `Class::Method`.
pub name: String,
/// The author-facing label Valve shows in the Pulse graph editor ("Get Abs Origin").
pub display: Option<String>,
/// The author-facing documentation string ("The entity origin (absolute).").
pub description: Option<String>,
/// Accessor returning the binding's static descriptor — a lazy-init singleton, NOT the bound
/// function. Useful as the anchor a runtime walks to reach the descriptor; useless as a locator.
pub descriptor: u64,
/// A second accessor of the same shape, for the binding's argument descriptor.
pub arg_descriptor: u64,
pub flags: PulseFlags,
}
/// The metadata words a Pulse binding carries, decoded.
///
/// Pulse is Source 2's TYPED graph VM, so a binding cannot be registered without the engine knowing how
/// it may be called — and it records that as data. Each decoded flag below is named for what it was
/// measured to separate across the 1,217 CS2 bindings, not for what its field is presumed to mean:
/// every one of them partitions the table almost perfectly along a naming convention that is
/// independent of the bytes.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub struct PulseFlags {
/// Free-function binding on a library class — no receiver. Set for every `CPulse*lib` / `*Funcs`
/// binding and, being the complement of `instance`, never together with it.
pub library: bool,
/// Binding requires an entity receiver. Separates the `*API::` classes from everything else
/// exactly: 345 of 353 `*API::` bindings set it, and 0 of the other 864 do.
pub instance: bool,
/// Binding writes state — the const-correctness bit. Across the entity APIs it is set on 100% of
/// `Set`/`Add`/`Remove`/`Destroy`/`Turn`/`Toggle`/`Play`/`Stop` bindings and on ~0% of
/// `Get`/`Is`/`Has`/`Find` ones.
pub mutating: bool,
/// Binding may suspend the calling cursor rather than returning within the frame — every record
/// carrying it is a `Wait` / `Yield` / `Pause` / timer-`Start` / long-running-sequence binding.
pub blocking: bool,
/// The two undecoded words as read, so a consumer can re-derive meaning if a later build repurposes
/// a bit rather than silently inheriting today's reading.
pub raw: (u32, u32),
}
impl PulseFlags {
// Both words hold their booleans one-per-BYTE rather than packed one-per-bit, which is what a
// plain `bool` struct member compiles to — so the flags are read as byte tests, not masks.
fn decode(w0: u32, w1: u32) -> Self {
PulseFlags {
library: w0 & 0xff != 0,
mutating: w0 >> 8 & 0xff != 0,
instance: w0 >> 16 & 0xff != 0,
blocking: w1 >> 3 & 1 != 0,
raw: (w0, w1),
}
}
}
/// The byte offset a FIELD descriptor records its member at. Established against the SchemaSystem at
/// **601/601** on records genuinely inside a datadesc array — the qualification that matters, because a
/// sweep of all writable data mostly finds the schema's OWN field tables, which carry a name pointer and
/// an offset too and therefore match themselves.
const FIELD_OFFSET_SLOT: u64 = 8;
/// One entity-IO input handler: the C++ method name, the input name a map fires, and the handler.
#[derive(Clone, Debug)]
pub struct DatadescInput {
/// The C++ handler name, e.g. `InputKill`. Not class-qualified BY THE RECORD — the descriptor
/// carries no owning class. [`datadesc_arrays`] recovers it from the array instead; see `class`.
pub handler: String,
/// The class that owns this handler, where the array it sits in identified one. `InputEnable` is a
/// distinct handler on 48 classes, and this is what tells them apart.
pub class: Option<String>,
/// The entity-IO input name a map or another entity fires, e.g. `Kill`.
pub io_name: String,
pub func: u64,
}
/// One entity-IO output: an event an entity fires, and the member holding its subscriber list.
///
/// The mirror of [`DatadescInput`] in the same array — an input is something you SEND an entity, an output
/// is something it TELLS you, which is what a mod hooks to react to gameplay. Carries a member OFFSET
/// rather than a function pointer: an output is data on the instance, not code.
#[derive(Clone, Debug)]
pub struct DatadescOutput {
/// The member holding the output, e.g. `m_OnStartTouch`.
pub member: String,
/// The entity-IO name a map wires to, e.g. `OnStartTouch`. Usually the member minus `m_`, but NOT
/// reliably so (`m_OnBombExplode` fires `BombExplode`), which is why both are kept.
pub output: String,
/// Byte offset of the member within its entity.
pub offset: u32,
}
/// Every entity-IO output the game library declares.
///
/// Shares the array and the stride with [`datadesc_inputs`]; the discriminator is structural rather than
/// lexical — an output has NO handler at `+40` (measured: zero for all 226 CS2 outputs, non-zero for every
/// input), because it is a subscriber list rather than a function.
pub fn datadesc_outputs(img: &CodeImage) -> Vec<DatadescOutput> {
let mut out = Vec::new();
scan_records(img, DATADESC_STRIDE, |at| {
let Some(member) = img.read_ptr(at).and_then(|p| table_string(img, p)) else {
return;
};
// Valve's output convention, the counterpart of the `Input` prefix the input reader keys on.
if !member.starts_with("m_On") || member.len() <= 4 {
return;
}
let (Some(output), Some(handler)) = (
img.read_ptr(at + 24).and_then(|p| table_string(img, p)),
img.read_ptr(at + 40),
) else {
return;
};
// A handler here means this is an INPUT record that happens to be named `m_On…`.
if handler != 0 {
return;
}
let Some(offset) = img.read_u32(at + 8).filter(|&o| o > 0 && o < (1 << 20)) else {
return;
};
out.push(DatadescOutput {
member,
output,
offset,
});
});
out.sort_by(|a, b| (&a.output, &a.member, a.offset).cmp(&(&b.output, &b.member, b.offset)));
out.dedup_by(|a, b| a.member == b.member && a.output == b.output && a.offset == b.offset);
out
}
/// One entity-factory record: the classname a map spawns by, and the C++ class it constructs.
///
/// Carries no function pointer — this is the vocabulary a level designer writes (`func_door`) bound to
/// the class the schema describes (`CBaseDoor`), which is the join a consumer needs to spawn or identify
/// an entity by name. It is NOT a naming source.
#[derive(Clone, Debug)]
pub struct EntityClass {
/// The map-facing classname, e.g. `func_door`.
pub classname: String,
/// The C++ class it constructs, e.g. `CBaseDoor` — a schema-registered class.
pub class: String,
}
/// Every entity classname the game library binds to a class.
///
/// Two adjacent string pointers, gated on their SHAPES being complementary: a map classname is
/// lowercase-with-underscores by Valve's own convention, and a C++ class name starts uppercase. That
/// asymmetry is what separates these records from the many other adjacent string pairs in `.data`.
pub fn entity_classes(img: &CodeImage) -> Vec<EntityClass> {
let map_name = |s: &str| {
s.len() >= 3
&& s.starts_with(|c: char| c.is_ascii_lowercase())
&& s.bytes()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'_')
};
// Source's universal class prefix: a capital `C` followed by another capital. Plain
// uppercase-first is far too loose — `.data` is full of adjacent string pairs, and it accepted
// `chicken_server` -> `GameSessionManifest_server`, two unrelated neighbours.
let cpp_name = |s: &str| {
s.len() >= 3
&& s.starts_with('C')
&& s.as_bytes()[1].is_ascii_uppercase()
&& s.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'_')
};
let mut out = Vec::new();
let mut seen = HashMap::new();
scan_records(img, 16, |at| {
let (Some(classname), Some(class)) = (
img.read_ptr(at).and_then(|p| table_string(img, p)),
img.read_ptr(at + 8).and_then(|p| table_string(img, p)),
) else {
return;
};
if !map_name(&classname) || !cpp_name(&class) {
return;
}
// One binding per classname; a duplicate that disagrees is dropped rather than guessed at.
match seen.entry(classname.clone()) {
std::collections::hash_map::Entry::Occupied(e) => {
if *e.get() != class {
out.retain(|x: &EntityClass| x.classname != classname);
}
}
std::collections::hash_map::Entry::Vacant(e) => {
e.insert(class.clone());
out.push(EntityClass { classname, class });
}
}
});
out.sort_by(|a, b| a.classname.cmp(&b.classname));
out
}
/// A name the binary vouches for, at an address it can be trusted to LOCATE.
#[derive(Clone, Debug)]
pub struct ValveName {
pub name: String,
pub addr: u64,
}
/// Read the NUL-terminated string at `vaddr`, rejecting anything that is not a short printable-ASCII
/// run. Every table field this validates is a C identifier or a UI string, so a pointer that lands on
/// binary data fails here rather than being carried forward as a garbage name.
fn table_string(img: &CodeImage, vaddr: u64) -> Option<String> {
if vaddr == 0 {
return None;
}
let s = img.read_c_string(vaddr)?;
(!s.is_empty() && s.len() <= MAX_STR && s.bytes().all(|c| (0x20..0x7f).contains(&c)))
.then_some(s)
}
/// Is `s` a fully-qualified `Class::Method` of C identifiers? The Pulse table's defining shape — and a
/// strong enough gate on its own that a false positive would have to be a deliberately-planted string
/// followed by two code pointers.
fn is_qualified(s: &str) -> bool {
let Some((class, method)) = s.split_once("::") else {
return false;
};
let ident = |p: &str| {
let mut c = p.chars();
c.next()
.is_some_and(|f| f.is_ascii_alphabetic() || f == '_')
&& c.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
};
ident(class) && ident(method)
}
/// Walk every writable data section in 8-byte steps, calling `f` with each candidate record address.
/// `stride` reserves the record's own length so a reader never runs off the end of its section.
fn scan_records(img: &CodeImage, stride: usize, mut f: impl FnMut(u64)) {
for (va, len) in img.data_blocks() {
let Some(last) = len.checked_sub(stride) else {
continue;
};
for off in (0..=last).step_by(8) {
f(va + off as u64);
}
}
}
/// Every Pulse binding `img` registers.
pub fn pulse_bindings(img: &CodeImage) -> Vec<PulseBinding> {
let mut out = Vec::new();
scan_records(img, PULSE_STRIDE, |at| {
let Some(name) = img.read_ptr(at).and_then(|p| table_string(img, p)) else {
return;
};
if !is_qualified(&name) {
return;
}
// Both code pointers are required: one alone also matches a plain `{name, …, fnptr}` pair,
// whereas a second in the very next slot is specific to a binding record.
let (Some(descriptor), Some(arg_descriptor)) =
(img.read_ptr(at + 24), img.read_ptr(at + 32))
else {
return;
};
if !img.is_code(descriptor) || !img.is_code(arg_descriptor) {
return;
}
out.push(PulseBinding {
name,
display: img.read_ptr(at + 8).and_then(|p| table_string(img, p)),
description: img.read_ptr(at + 16).and_then(|p| table_string(img, p)),
descriptor,
arg_descriptor,
flags: PulseFlags::decode(
img.read_u32(at + 56).unwrap_or(0),
img.read_u32(at + 60).unwrap_or(0),
),
});
});
out
}
/// Every entity-IO input handler `img` declares.
pub fn datadesc_inputs(img: &CodeImage) -> Vec<DatadescInput> {
let mut out = Vec::new();
scan_records(img, DATADESC_STRIDE, |at| {
let Some(handler) = img.read_ptr(at).and_then(|p| table_string(img, p)) else {
return;
};
// `Input` + at least one more character: the naming convention Valve's entity-IO macros emit,
// and what separates the input records from the field and output descriptors sharing the array.
if !handler.starts_with("Input") || handler.len() <= 5 {
return;
}
let (Some(io_name), Some(func)) = (
img.read_ptr(at + 24).and_then(|p| table_string(img, p)),
img.read_ptr(at + 40),
) else {
return;
};
if !img.is_code(func) {
return;
}
out.push(DatadescInput {
handler,
class: None,
io_name,
func,
});
});
out
}
/// One datadesc array: the FIELD descriptors that identify its owning class, and the input handlers
/// that class owns.
pub struct DatadescArray {
/// `(member name, byte offset)` for each field descriptor, which the SchemaSystem states
/// independently — the fingerprint that names the array's class.
pub fields: Vec<(String, i32)>,
pub inputs: Vec<DatadescInput>,
}
/// Segment the datadesc into ARRAYS, so an input handler can be attributed to the class that owns it.
///
/// The record carries no owning class, which is why `names` has to drop a handler name the table gives
/// more than one address. But the array does: it also holds FIELD descriptors, and a field is a
/// `(member, offset)` pair the SchemaSystem states from a completely different table. The class whose
/// schema contains EVERY pair in an array owns the array, and therefore owns its handlers.
///
/// An array is found from a confirmed input outward — a record with a name at `+0` continues it — so a
/// run is anchored on something already known to be a datadesc record rather than on a shape guess.
pub fn datadesc_arrays(img: &CodeImage) -> Vec<DatadescArray> {
let named = |at: u64| img.read_ptr(at).and_then(|p| table_string(img, p));
let input_at = |at: u64| -> Option<DatadescInput> {
let handler = named(at).filter(|h| h.starts_with("Input") && h.len() > 5)?;
let io_name = img.read_ptr(at + 24).and_then(|p| table_string(img, p))?;
let func = img.read_ptr(at + 40).filter(|&f| img.is_code(f))?;
Some(DatadescInput {
handler,
class: None,
io_name,
func,
})
};
let mut out = Vec::new();
let mut claimed: Vec<(u64, u64)> = Vec::new();
for (va, len) in img.data_blocks() {
let Some(last) = len.checked_sub(DATADESC_STRIDE) else {
continue;
};
let end = va + last as u64;
let mut at = va;
while at <= end {
if input_at(at).is_none() {
at += 8;
continue;
}
if claimed.iter().any(|&(s, e)| at >= s && at <= e) {
at += DATADESC_STRIDE as u64;
continue;
}
let stride = DATADESC_STRIDE as u64;
let (mut lo, mut hi) = (at, at);
while lo >= va + stride && named(lo - stride).is_some() {
lo -= stride;
}
while hi + stride <= end && named(hi + stride).is_some() {
hi += stride;
}
claimed.push((lo, hi));
let (mut fields, mut inputs) = (Vec::new(), Vec::new());
let mut cur = lo;
while cur <= hi {
if let Some(i) = input_at(cur) {
inputs.push(i);
} else if let Some(n) = named(cur).filter(|n| n.starts_with("m_") && n.len() > 3)
&& let Some(o) = img.read_u32(cur + FIELD_OFFSET_SLOT)
{
fields.push((n, o as i32));
}
cur += stride;
}
if !inputs.is_empty() {
out.push(DatadescArray { fields, inputs });
}
at = hi + stride;
}
}
out
}
/// The names the tables can be trusted to LOCATE, with AMBIGUOUS ones dropped.
///
/// **Only the entity-IO datadesc qualifies.** The Pulse table's two code pointers are both descriptor
/// accessors, not the bound function: every one of the 485 CS2 `libserver` bindings measures the same
/// empty footprint (`int=0 float=0`), and they disassemble to a lazy-init singleton that returns a
/// pointer to a static descriptor. Folding a Pulse name would ship `CBaseEntityAPI::GetAbsOrigin`
/// pointing at a zero-argument accessor instead of the getter — a locator that resolves, passes live
/// validation as executable code, and is still the wrong function. The bindings are shipped as a
/// registry instead, where the address is labelled for what it is.
///
/// A name is ambiguous when the table gives it more than one address in this image: `InputEnable` is a
/// distinct handler on each of 48 classes and the record carries no owning class to tell them apart, so
/// picking one is a coin flip. The whole name is dropped instead. Returns `(names, dropped)`.
pub fn names(inputs: &[DatadescInput]) -> (Vec<ValveName>, usize) {
// A handler whose array identified its class is keyed by the QUALIFIED name, which is what makes it
// unambiguous: `InputEnable` on 48 classes is 48 distinct names once each carries its own. Only the
// ones still unqualified can collide, and those still drop rather than guess.
let qualified: Vec<String> = inputs
.iter()
.map(|i| match &i.class {
Some(c) => format!("{c}::{}", i.handler),
None => i.handler.clone(),
})
.collect();
let mut by_name: HashMap<&str, Vec<u64>> = HashMap::new();
for (i, q) in inputs.iter().zip(&qualified) {
let e = by_name.entry(q.as_str()).or_default();
if !e.contains(&i.func) {
e.push(i.func);
}
}
let dropped = by_name.values().filter(|a| a.len() > 1).count();
let mut out: Vec<ValveName> = by_name
.into_iter()
.filter(|(_, a)| a.len() == 1)
.map(|(name, a)| ValveName {
name: name.to_string(),
addr: a[0],
})
.collect();
out.sort_by(|x, y| x.name.cmp(&y.name)); // deterministic fold order
(out, dropped)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn qualified_names_are_two_c_identifiers() {
assert!(is_qualified("CBaseEntityAPI::GetAbsOrigin"));
assert!(is_qualified("_Priv::_m0"));
assert!(!is_qualified("CBaseEntityAPI")); // unqualified
assert!(!is_qualified("::GetAbsOrigin")); // empty class
assert!(!is_qualified("CFoo::9Bar")); // method starts with a digit
assert!(!is_qualified("CFoo<int>::Bar")); // a demangled template, not a table string
assert!(!is_qualified("Get Abs Origin")); // the display name, not the qualified one
}
#[test]
fn a_class_qualified_handler_is_no_longer_ambiguous() {
// The same handler NAME on two classes is a collision only while both are unqualified. Once the
// array's field descriptors identify the owner, they are two distinct names and BOTH survive —
// which is the whole point of reading the array rather than the record.
let q = |handler: &str, class: &str, func| DatadescInput {
handler: handler.into(),
class: Some(class.into()),
io_name: "Enable".into(),
func,
};
let (names, dropped) = names(&[
q("InputEnable", "CBaseDoor", 0x100),
q("InputEnable", "CBaseTrigger", 0x200),
]);
assert_eq!(dropped, 0);
assert_eq!(names.len(), 2);
assert_eq!(names[0].name, "CBaseDoor::InputEnable");
assert_eq!(names[1].name, "CBaseTrigger::InputEnable");
}
#[test]
fn a_handler_name_at_several_addresses_is_dropped_not_guessed() {
let input = |handler: &str, io: &str, func| DatadescInput {
handler: handler.into(),
class: None,
io_name: io.into(),
func,
};
let (names, dropped) = names(&[
input("InputKill", "Kill", 0x100),
// The same handler NAME on two classes — the record carries no owning class, so neither
// address can be claimed for the bare name.
input("InputEnable", "Enable", 0x200),
input("InputEnable", "Enable", 0x300),
// The same handler serving two inputs is ONE function, not an ambiguity.
input("InputToggle", "Toggle", 0x400),
input("InputToggle", "ToggleAlias", 0x400),
]);
assert_eq!(dropped, 1);
let got: Vec<(&str, u64)> = names.iter().map(|n| (n.name.as_str(), n.addr)).collect();
assert_eq!(got, [("InputKill", 0x100), ("InputToggle", 0x400)]); // sorted, no InputEnable
}
#[test]
fn flags_decode_per_byte_not_per_bit() {
// A library getter: library byte set, mutating and instance clear.
let lib_get = PulseFlags::decode(0x00_00_01, 0);
assert!(lib_get.library && !lib_get.mutating && !lib_get.instance);
// An entity setter: instance + mutating, library clear.
let api_set = PulseFlags::decode(0x01_01_00, 0);
assert!(api_set.instance && api_set.mutating && !api_set.library);
// An entity getter: instance only.
let api_get = PulseFlags::decode(0x01_00_00, 0);
assert!(api_get.instance && !api_get.mutating);
// The yield bit lives in the second word.
assert!(PulseFlags::decode(0x00_01_00, 0x08).blocking);
assert!(!PulseFlags::decode(0x00_01_00, 0x20).blocking);
// The raw words survive decoding so a later build can be re-read.
assert_eq!(api_set.raw, (0x01_01_00, 0));
}
}