ship one record per function: merge the release set, gen reads it, descriptions as doc comments, gates for what was only claimed; v3.0
Some checks failed
CI / fuzz (push) Successful in 2m2s
CI / lint (push) Successful in 15s
CI / test (push) Failing after 18s

This commit is contained in:
Kamal Tufekcic 2026-08-02 22:01:36 +03:00
commit 3410a79b6a
28 changed files with 30596 additions and 955 deletions

View file

@ -24,10 +24,12 @@
//! 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
//! stable across builds (a thunk stays a thunk), so they don't manufacture false transitions — the
//! 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.
//! stable across builds (a thunk stays a thunk), so they don't manufacture false transitions. `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, which is what lets a shape measured in one build
//! be compared against the model's consensus in the next — see `pipeline::AbiSig::differs`, which treats an
//! `Unknown` return class as "no disagreement" for exactly this reason, and the derive-time
//! `FlagReason::AbiDrift` check that reports the survivors.
//!
//! 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
@ -594,6 +596,172 @@ pub fn abi_shape(img: &CodeImage, entry: u64) -> Option<AbiShape> {
})
}
/// The 16 general-purpose registers as a slot index, sub-registers folded to their 64-bit parent.
///
/// `pub(crate)` because it is a fixed SysV fact, not per-pass tuning: `concmd` and `vscript` index
/// `[_; 16]` arrays by exactly this mapping and each carried its own copy of it. (Unlike `MAX_NAME`, or
/// the two `V` lattices, which differ between those readers deliberately.)
pub(crate) fn gp_slot(r: Register) -> Option<usize> {
let full = r.full_register();
(full.is_gpr64() && full != Register::RIP).then(|| full as usize - Register::RAX as usize)
}
/// Registers a `call` destroys — every caller-saved GPR. A pointer that SURVIVES a call is in a
/// callee-saved register, which is exactly how a real `this` is kept across one.
///
/// One list, three shapes: this array, [`caller_saved_mask`]'s bitmask, and the slot indices `concmd`
/// clears after a call. They must agree — a register missing from one and present in another is a
/// tracker that forgets a value the machine kept, or keeps one the machine destroyed.
pub(crate) const CALLER_SAVED: [Register; 9] = [
Register::RAX,
Register::RCX,
Register::RDX,
Register::RSI,
Register::RDI,
Register::R8,
Register::R9,
Register::R10,
Register::R11,
];
fn caller_saved_mask() -> u32 {
CALLER_SAVED
.iter()
.filter_map(|&r| gp_slot(r))
.fold(0u32, |m, s| m | (1 << s))
}
/// The largest displacement the function reaches through the pointer it was handed in RDI — for a
/// member function, how far into `this` it touches.
///
/// **What it is for.** Every other check in this project verifies that a locator RESOLVES; none verifies
/// that it resolves to the RIGHT function. This one can, against a fact the SchemaSystem already states
/// offline: a `CFoo::` method reaches its own object through `this`, so every `this + N` it touches must
/// satisfy `N < sizeof(CFoo)`. Reaching past the end means the pointer is not a `CFoo`.
///
/// **Deliberately conservative, in the same direction as `abi_shape`.** A register stops holding `this`
/// on any write that is not a move from another register already holding it, every caller-saved register
/// is dropped across a `call`, and a path merge keeps only what holds on BOTH paths. So `this` is followed
/// only where it is provably still `this`, and the error direction is a reach that is too SMALL — a missed
/// contradiction rather than a false accusation against a correct entry.
///
/// Indexed memory operands (`(%rax,%rcx,8)`) are skipped: there the displacement is an array base rather
/// than a field offset, so its magnitude says nothing about the object's size.
pub fn this_reach(img: &CodeImage, entry: u64) -> Option<u64> {
const MAX_SPAN: usize = 16 * 1024;
const MAX_STEPS: usize = 6000;
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;
let clobber = caller_saved_mask();
let mut factory = InstructionInfoFactory::new();
let mut seen: HashMap<u64, u32> = HashMap::new();
let rdi = 1u32 << gp_slot(Register::RDI)?;
let mut work = vec![(entry, rdi)];
let mut best: Option<u64> = None;
let mut steps = 0usize;
let mut insn = Instruction::default();
while let Some((ip, incoming)) = work.pop() {
if !in_span(ip) {
continue;
}
steps += 1;
if steps > MAX_STEPS {
break;
}
// Path merge is INTERSECTION: a register holds `this` here only if it did on every path in.
let held = match seen.get(&ip) {
Some(&prev) => {
let merged = prev & incoming;
if merged == prev {
continue; // nothing new to propagate
}
merged
}
None => incoming,
};
seen.insert(ip, held);
let off = (ip - entry) as usize;
let mut dec = Decoder::with_ip(64, &code[off..], ip, DecoderOptions::NONE);
if !dec.can_decode() {
continue;
}
dec.decode_out(&mut insn);
if insn.is_invalid() || insn.len() == 0 {
continue;
}
// Record every field access made through a register that still holds `this`.
if insn.memory_index() == Register::None
&& let Some(slot) = gp_slot(insn.memory_base())
&& held & (1 << slot) != 0
&& (0..insn.op_count()).any(|i| insn.op_kind(i) == OpKind::Memory)
{
let d = insn.memory_displacement64();
if d < MAX_SPAN as u64 {
best = Some(best.map_or(d, |b: u64| b.max(d)));
}
}
// Propagate. A plain 64-bit register-to-register move carries `this`; anything else that writes
// a register destroys whatever it held.
let mut next = held;
let is_reg_move = insn.mnemonic() == Mnemonic::Mov
&& insn.op_count() == 2
&& insn.op0_kind() == OpKind::Register
&& insn.op1_kind() == OpKind::Register
&& insn.op0_register().is_gpr64();
let carried = is_reg_move
.then(|| gp_slot(insn.op1_register()))
.flatten()
.filter(|&s| held & (1 << s) != 0)
.and_then(|_| gp_slot(insn.op0_register()));
for used in factory.info(&insn).used_registers() {
if matches!(
used.access(),
OpAccess::Write | OpAccess::ReadWrite | OpAccess::CondWrite
) && let Some(s) = gp_slot(used.register())
{
next &= !(1 << s);
}
}
if let Some(s) = carried {
next |= 1 << s;
}
if insn.flow_control() == FlowControl::Call
|| insn.flow_control() == FlowControl::IndirectCall
{
next &= !clobber;
}
let after = ip + insn.len() as u64;
match insn.flow_control() {
FlowControl::Return
| FlowControl::IndirectBranch
| FlowControl::Exception
| FlowControl::Interrupt => {}
FlowControl::UnconditionalBranch => {
let t = insn.near_branch_target();
if in_span(t) {
work.push((t, next));
}
}
FlowControl::ConditionalBranch => {
work.push((after, next));
let t = insn.near_branch_target();
if in_span(t) {
work.push((t, next));
}
}
_ => work.push((after, next)),
}
}
best
}
#[cfg(test)]
mod tests {
use super::*;
@ -803,4 +971,79 @@ mod tests {
// ret — no result register written before returning.
assert_eq!(shape_of(&[0xC3]).ret_class, RetClass::Void);
}
// ---- this_reach: the identity check's measurement half. Every case here is one the FIELD-tracking
// has to get right for the check to be usable as a rejection rather than a hint. ----
fn reach_of(bytes: &[u8]) -> Option<u64> {
this_reach(&CodeImage::for_test(0x1000, bytes), 0x1000)
}
#[test]
fn this_reach_follows_a_move_into_a_callee_saved_register() {
// mov %rdi,%r13 ; cmpb $0,0x7bc(%r13) ; ret
// The shape that matters in practice: the prologue stashes `this` and every field access is
// through the copy, so a tracker that only watches RDI measures nothing.
assert_eq!(
reach_of(&[
0x49, 0x89, 0xFD, 0x41, 0x80, 0xBD, 0xBC, 0x07, 0x00, 0x00, 0x00, 0xC3
]),
Some(0x7bc)
);
}
#[test]
fn this_reach_stops_at_a_reloaded_register() {
// mov 0x10(%rdi),%rdi ; mov 0x110(%rdi),%rax ; ret
// RDI is REDEFINED from memory, so 0x110 is an offset into a different object. Crediting it to
// `this` is exactly the false positive that made an earlier prototype of this check unusable.
assert_eq!(
reach_of(&[
0x48, 0x8B, 0x7F, 0x10, 0x48, 0x8B, 0x87, 0x10, 0x01, 0x00, 0x00, 0xC3
]),
Some(0x10)
);
}
#[test]
fn this_reach_drops_caller_saved_registers_across_a_call() {
// call +0 ; mov 0x200(%rdi),%rax ; ret
// RDI is caller-saved, so after a call it holds whatever the callee left. A read through it is
// not a read of `this`.
assert_eq!(
reach_of(&[
0xE8, 0x00, 0x00, 0x00, 0x00, 0x48, 0x8B, 0x87, 0x00, 0x02, 0x00, 0x00, 0xC3
]),
None
);
}
#[test]
fn this_reach_keeps_callee_saved_copies_across_a_call() {
// mov %rdi,%rbx ; call +0 ; mov 0x200(%rbx),%rax ; ret
// The counterpart: RBX is callee-saved, so the copy survives and the access IS through `this`.
assert_eq!(
reach_of(&[
0x48, 0x89, 0xFB, 0xE8, 0x00, 0x00, 0x00, 0x00, 0x48, 0x8B, 0x83, 0x00, 0x02, 0x00,
0x00, 0xC3
]),
Some(0x200)
);
}
#[test]
fn this_reach_ignores_indexed_operands() {
// mov 0x900(%rdi,%rcx,8),%rax ; ret — an array walk; the displacement is a base, not a field
// offset, so its magnitude says nothing about the object's size.
assert_eq!(
reach_of(&[0x48, 0x8B, 0x84, 0xCF, 0x00, 0x09, 0x00, 0x00, 0xC3]),
None
);
}
#[test]
fn this_reach_is_none_when_this_is_never_dereferenced() {
// xor %eax,%eax ; ret — a constant returner touches no object at all.
assert_eq!(reach_of(&[0x31, 0xC0, 0xC3]), None);
}
}

View file

@ -74,7 +74,17 @@ 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];
///
/// DERIVED from `abi::CALLER_SAVED` rather than re-listed. It is a fixed SysV fact and was spelled out
/// three times across two readers and the ABI measurer; a register present in one list and missing from
/// another is a tracker that either forgets a value the machine kept or keeps one it destroyed.
fn clobbered() -> [usize; 9] {
let mut out = [0usize; 9];
for (i, &r) in crate::abi::CALLER_SAVED.iter().enumerate() {
out[i] = crate::abi::gp_slot(r).expect("every caller-saved register is a GPR");
}
out
}
/// Longest string accepted as a command name. Names are identifiers; anything longer is not one, so the
/// cap doubles as a validity gate.
@ -195,10 +205,10 @@ pub fn flag_names(flags: u64) -> Vec<&'static str> {
}
/// Index 0-15 of a GPR, after widening an 8/16/32-bit name to its 64-bit parent.
/// The 64-bit parent register as a slot index. Thin wrapper over [`crate::abi::gp_slot`] — the mapping is a
/// fixed SysV fact, and this file only narrows it to the `u8` its `[_; 16]` arrays index by.
fn gpr(r: Register) -> Option<u8> {
let f = r.full_register();
f.is_gpr64()
.then(|| (f as usize - Register::RAX as usize) as u8)
crate::abi::gp_slot(r).map(|s| s as u8)
}
/// What a register provably holds. `Sym` is an offset from a value we never learned — a constructor's
@ -233,6 +243,18 @@ impl V {
}
}
/// End a register's current life, because something has overwritten it.
///
/// Called by EVERY arm that assigns to a register, not only the catch-all — which is the correction that
/// matters. `mov`, `lea` and `xor` re-point a register just as surely as an unmodelled instruction does,
/// so leaving their epoch alone let a base register be aimed at a second object while stores made against
/// the FIRST still keyed to the same `(register, epoch)` pair — and a `lea rdx,[rbx+0x1c8]` for object B
/// could then match a `mov [rbx+0x1e8],rax` that belonged to object A, attributing one constructor's
/// handler to another's registration.
fn end_life(epoch: &mut [u32; 16], d: u8) {
epoch[d as usize] = epoch[d as usize].saturating_add(1);
}
/// Address of a `this`-relative slot: base register, that register's epoch, displacement.
type Slot = (u8, u32, i64);
@ -285,13 +307,24 @@ fn inits_invalid_handle(img: &CodeImage, f: u64) -> bool {
}
/// 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()
/// Whether `s` is shaped like a console COMMAND name.
///
/// Split from the read so a test can call the rule instead of restating it — restating it is how the
/// convar test came to assert this rule while claiming to pin the other one, and would have passed with
/// the two gates swapped.
///
/// Deliberately looser than [`is_convar_name`]: a command name may lead with punctuation, because the
/// `+bugvoice` / `-bugvoice` on/off pairs are real commands and a convar can never be spelled that way.
fn is_cmd_name(s: &str) -> bool {
!s.is_empty()
&& s.len() <= MAX_NAME
&& s.bytes()
.all(|c| c.is_ascii_graphic() && c != b'"' && c != b'%');
ok.then_some(s)
.all(|c| c.is_ascii_graphic() && c != b'"' && c != b'%')
}
fn cmd_name(img: &CodeImage, va: u64) -> Option<String> {
let s = img.read_c_string(va)?;
is_cmd_name(&s).then_some(s)
}
/// Every console command `img` registers.
@ -316,10 +349,7 @@ fn collect_sites(
img: &CodeImage,
mut accept: impl FnMut(&CodeImage, u64, &[V; 16]) -> bool,
) -> Vec<Site> {
let mut entries = crate::locate::candidate_entries(img);
entries.extend(img.eh_frame_functions().into_iter().map(|(s, _)| s));
entries.sort_unstable();
entries.dedup();
let entries = crate::locate::function_entries(img);
let mut sites: Vec<Site> = Vec::new();
let mut factory = InstructionInfoFactory::new();
@ -351,7 +381,13 @@ fn collect_sites(
if accept(img, t, &val) {
found.push((t, val));
}
for c in CLOBBER {
// A call assigns to NINE registers at once, so it ends nine lives — the arm that most
// needs the epoch bump and the one that was missing it. The Lea arm below mints a fresh
// symbolic base for any unknown-valued register, and the store arm keys that base as
// `(reg, epoch, disp)`: without the bump, `rax` after two successive calls is ONE key
// space shared by two objects, where same-displacement stores overwrite each other.
for c in clobbered() {
end_life(&mut epoch, c as u8);
val[c] = V::Unknown;
}
continue;
@ -385,6 +421,7 @@ fn collect_sites(
// `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()) {
end_life(&mut epoch, d);
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 {
@ -401,6 +438,7 @@ fn collect_sites(
}
Mnemonic::Mov => {
if let Some(d) = gpr(insn.op0_register()) {
end_life(&mut epoch, d);
val[d as usize] = match insn.op1_kind() {
OpKind::Immediate8to64
| OpKind::Immediate32to64
@ -419,6 +457,7 @@ fn collect_sites(
Mnemonic::Xor => {
if let (Some(d), Some(s)) = (gpr(insn.op0_register()), gpr(insn.op1_register()))
{
end_life(&mut epoch, d);
val[d as usize] = if d == s { V::Const(0) } else { V::Unknown };
}
}
@ -432,8 +471,8 @@ fn collect_sites(
OpAccess::Write | OpAccess::ReadWrite | OpAccess::CondWrite
) && let Some(d) = gpr(ur.register())
{
end_life(&mut epoch, d);
val[d as usize] = V::Unknown;
epoch[d as usize] = epoch[d as usize].saturating_add(1);
}
}
}
@ -508,62 +547,6 @@ fn interpret_commands(img: &CodeImage, sites: &[Site]) -> Vec<ConsoleCommand> {
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)));
}
}
/// How many call sites must present the convar argument shape before a target counts as a registrar.
///
/// This is the whole safety margin for identifying convar registration by shape rather than by a semantic
@ -597,16 +580,24 @@ pub struct ConVar {
/// Stricter than [`cmd_name`], which admits any printable run because commands like `+bugvoice` exist.
/// A convar name is always an identifier, and the tighter gate is what keeps prose out of the name slot
/// when the shape test is the only thing standing between a call site and a record.
fn convar_name(img: &CodeImage, va: u64) -> Option<String> {
let s = img.read_c_string(va)?;
let ok = !s.is_empty()
/// Whether `s` is shaped like a CONVAR name — stricter than [`is_cmd_name`] in both directions: it must
/// LEAD with a letter or underscore, and its body admits only `[A-Za-z0-9_.]`.
///
/// This gate is the only shape check between a call site and an emitted ConVar record, so it is what keeps
/// prose out of the name slot.
fn is_convar_name(s: &str) -> bool {
!s.is_empty()
&& s.len() <= MAX_NAME
&& s.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& s.bytes()
.all(|c| c.is_ascii_alphanumeric() || c == b'_' || c == b'.');
ok.then_some(s)
.all(|c| c.is_ascii_alphanumeric() || c == b'_' || c == b'.')
}
fn convar_name(img: &CodeImage, va: u64) -> Option<String> {
let s = img.read_c_string(va)?;
is_convar_name(&s).then_some(s)
}
/// Help text: prose, or nothing. Deliberately permissive about content and strict about being a real
@ -844,3 +835,110 @@ pub fn convars(img: &CodeImage, library: &str) -> Vec<ConVar> {
out.dedup_by(|a, b| a.name == b.name && a.addr == b.addr);
out
}
#[cfg(test)]
mod tests {
use super::*;
// ---- command reader ----
#[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 every_arm_that_clobbers_a_register_ends_its_life() {
// The invariant `end_life` documents, checked against the arm that breaks it most cheaply. A
// CALL assigns to nine caller-saved registers at once; leaving their epochs alone made `rax`
// after two successive calls one key space shared by two objects, so a store to `[rax+0x18]`
// made through the FIRST could be read back as a slot of the SECOND — and the member-callback
// recovery ships whatever executable pointer that merged window holds.
let mut epoch = [0u32; 16];
let clobber = clobbered();
let before: Vec<u32> = clobber.iter().map(|&c| epoch[c]).collect();
for c in clobbered() {
end_life(&mut epoch, c as u8);
}
for (i, &c) in clobber.iter().enumerate() {
assert_eq!(
epoch[c],
before[i] + 1,
"register {c} kept its epoch across a call"
);
// …and the two runs are therefore distinguishable keys, which is the point.
assert_ne!(
V::Sym(c as u8, before[i], 0x18),
V::Sym(c as u8, epoch[c], 0x18)
);
}
// A callee-saved register is NOT clobbered: the registration `this` a constructor threads
// through survives the call, which is what the epoch widening was for in the first place.
for saved in [3u8 /* rbx */, 12, 13, 14, 15] {
assert!(
!clobber.contains(&(saved as usize)),
"r{saved} is callee-saved and must survive a call"
);
}
}
#[test]
fn a_command_name_is_an_identifier_not_prose() {
// Calls the shipped rule, for the same reason as the convar test below it.
assert!(is_cmd_name("bot_add"));
assert!(is_cmd_name("+bugvoice")); // an on/off pair is a real command name
assert!(!is_cmd_name("")); // an empty string is not a name
assert!(!is_cmd_name("Adds a bot matching the given criteria.")); // a description (spaces)
assert!(!is_cmd_name("%s: no varname specified\n")); // a format string
assert!(!is_cmd_name(&"x".repeat(MAX_NAME + 1)));
}
// ---- convar reader ----
#[test]
fn a_convar_name_is_stricter_than_a_command_name() {
// CALLS the gate rather than restating it. The previous version of this test re-implemented the
// COMMAND rule and asserted only inputs both rules agree on, so it would have passed with the two
// gates swapped — the exact regression it is named for.
assert!(is_convar_name("sv_cheats"));
assert!(is_convar_name("mp_roundtime_defuse"));
assert!(is_convar_name("_internal.thing")); // leading underscore and a dot are both legal
assert!(!is_convar_name(""));
assert!(!is_convar_name("Set to 1 to enable cheats")); // help text, not a name
assert!(!is_convar_name(&"x".repeat(MAX_NAME + 1)));
// The DISCRIMINATING cases — the ones that fail if the two gates are confused. A command may lead
// with punctuation (the `+`/`-` on/off pairs); a convar may not, and admits no other punctuation.
assert!(is_cmd_name("+bugvoice") && !is_convar_name("+bugvoice"));
assert!(is_cmd_name("1st_arg") && !is_convar_name("1st_arg")); // digit-led
assert!(is_cmd_name("say/all") && !is_convar_name("say/all"));
}
}

View file

@ -109,6 +109,21 @@ fn kind_tag_of(sym: &str) -> Option<KindTag> {
const PT_GNU_EH_FRAME: u32 = 0x6474_e550;
impl CodeImage {
/// A bare image wrapping one executable span — enough for a decoder test to run the REAL analysis
/// over hand-assembled bytes instead of a parallel mock of it.
#[cfg(test)]
pub fn for_test(vaddr: u64, code: &[u8]) -> Self {
Self {
data: code.to_vec(),
exec: vec![(0, vaddr, code.len())],
secs: Vec::new(),
sym_addr: HashMap::new(),
reloc: HashMap::new(),
reloc_by_val: HashMap::new(),
kind_at: HashMap::new(),
}
}
pub fn load(path: &Path) -> Result<Self> {
let data = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;
Self::from_bytes(data)
@ -406,6 +421,16 @@ impl CodeImage {
self.data_at(vaddr, 8).map(|b| u64le(b, 0))
}
/// Does a relocation land ON this slot — i.e. is the qword here a POINTER the linker resolved,
/// rather than a compile-time literal?
///
/// The distinction is what separates two records that are otherwise byte-compatible: a table of
/// `{ name, integer }` pairs and a table of `{ name, pointer }` pairs read identically until you ask
/// whether the second word was relocated.
pub fn is_reloc_slot(&self, vaddr: u64) -> bool {
self.reloc.contains_key(&vaddr)
}
/// Slot vaddrs whose (relocated) pointer value equals `target`.
pub fn ptrs_to(&self, target: u64) -> &[u64] {
self.reloc_by_val.get(&target).map_or(&[], |v| v.as_slice())

View file

@ -5,11 +5,15 @@
//! out and scraping stdout.
//!
//! # Supported API surface
//! A fork or embedder calls into these. Every engine entry point takes an explicit `&profile::GameProfile`
//! (there is NO process-global — CS2 and Dota can be derived in the same process):
//! A fork or embedder calls into these. Every engine entry point that needs game-specific knowledge takes
//! an explicit `&profile::GameProfile` — there is NO process-global, so CS2 and Dota can be derived in the
//! same process. (`classify_change_cmd` is the one exception, and takes none because it needs none: it
//! compares two builds of one named library and reads nothing game-specific.)
//! - [`pipeline`] — the pure OFFLINE derivation engine (nothing here attaches to a running server):
//! `corpus_model_cmd` (distill the corpus model), `fold_model_cmd` (roll model N → N+1), `backfill_cmd`
//! (cross-build name/offset timelines), plus the `ClassScope` / `CorpusSource` inputs.
//! `corpus_model_cmd` (distill the corpus model, taking a [`pipeline::ClassScope`]), `fold_model_cmd`
//! (roll model N → N+1), `backfill_cmd` (cross-build name/offset timelines). The derive that consumes
//! a corpus source is reached through `produce::produce_cmd`, which builds one internally from its
//! `--corpus` / `--corpus-model` arguments — `CorpusSource` itself is crate-private.
//! - [`produce`] — CI orchestration + the LIVE half (everything that drives a running server): `produce_cmd`
//! (the whole per-game build — boots its own bots server for validate-live + typed netvars when a game is
//! given), `integration_test_cmd` (the standalone live oracle), `classify_change_cmd` / `filter_corpus_cmd`
@ -44,6 +48,7 @@ pub mod schema;
pub mod sig;
pub mod taxonomy;
pub mod valvetab;
pub mod vscript;
pub mod xref;
// The canonical model + emitters live in the deriver-free `source2rosetta-core` crate; re-export them so

View file

@ -1,6 +1,15 @@
//! Read-only window into a *running* CS2 server's memory — the runtime oracle that verifies the
//! offline derivations against ground truth. No injection, no debugger: just `/proc/<pid>/mem` (needs
//! ptrace access — same-user with `yama/ptrace_scope=0`, or `CAP_SYS_PTRACE`).
//! Window into a *running* CS2 server — the runtime oracle that verifies the offline derivations against
//! ground truth. Needs ptrace access (same-user with `yama/ptrace_scope=0`, or `CAP_SYS_PTRACE`).
//!
//! **Mostly reading, but not only reading, and the difference is worth stating plainly.** The bulk of this
//! module reads `/proc/<pid>/mem`. Two things go further: `poke_bytes` writes bytes in with
//! `PTRACE_POKEDATA`, and [`call_remote`] ATTACHES, saves the main thread's registers, builds a call frame
//! and executes a function in the live process before restoring the thread exactly. Both exist because
//! some claims cannot be checked any other way — a lazy-init singleton is zeroed until something calls its
//! accessor — and both are used only against the narrow set of functions the derivation has already
//! measured as safe to call (nullary, `this`-only, no game state). Nothing is injected and nothing
//! persists: the process is left as it was found, and a faulting call is caught and the thread restored
//! rather than allowed to kill the server.
//!
//! Offline we resolve `.rela.dyn` by hand to recover as-loaded pointer values; the running process is
//! the authority on what those values actually are. So reading the same structures live and comparing
@ -37,6 +46,19 @@ fn maps_path(line: &str) -> &str {
rest.trim_start()
}
/// The scheduler state character from `/proc/<pid>/stat` (`R`/`S`/`D`/`Z`/`T`/…), or `None` if the process
/// is gone entirely. Parsed from AFTER the final `)`, because the comm field is parenthesised and may itself
/// contain spaces and brackets — splitting the line on whitespace from the left gets this wrong for any
/// process whose name has a space in it.
fn proc_state(pid: u32) -> Option<char> {
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
stat[stat.rfind(')')? + 1..]
.split_whitespace()
.next()?
.chars()
.next()
}
impl LiveProcess {
pub fn attach(pid: u32) -> Result<Self> {
let maps = std::fs::read_to_string(format!("/proc/{pid}/maps"))
@ -80,10 +102,30 @@ impl LiveProcess {
}
}
executable.sort_unstable();
// Distinguish the two ways this fails, because they call for opposite responses and the kernel
// reports BOTH as EACCES. If the process is gone, `/proc/<pid>` is gone with it — so check that
// first: a server that CRASHED mid-derive otherwise reads as a permissions problem, and the
// operator goes off tuning `ptrace_scope` for a fault that had nothing to do with it. (Seen: a
// CS2 server crashed in Steam auth and this line blamed ptrace.)
let mem = File::open(format!("/proc/{pid}/mem")).with_context(|| {
format!(
"open /proc/{pid}/mem — needs ptrace access (yama ptrace_scope=0 or run as root)"
)
match proc_state(pid) {
// A crashed child stays a ZOMBIE until the parent reaps it, so `/proc/<pid>` still exists
// and only `mem` is unreadable — an existence check alone reports it as a permissions
// fault. Read the state instead.
Some('Z') | None => format!(
"the game process {pid} DIED during the live stage — it is {}, so there is nothing \
left to read. This is NOT a ptrace-permission problem: check the server's own log \
and /tmp/dumps for a minidump.",
if proc_state(pid) == Some('Z') {
"a zombie (crashed, not yet reaped)"
} else {
"gone"
}
),
Some(_) => format!(
"open /proc/{pid}/mem — needs ptrace access (yama ptrace_scope=0 or run as root)"
),
}
})?;
Ok(Self {
mem,
@ -207,9 +249,6 @@ impl LiveProcess {
pub struct CallResult {
pub rax: u64,
pub clean_return: bool,
/// Where the scratch blob was placed, so the caller can read back what the callee wrote into it.
/// Zero when the call carried no scratch.
pub scratch_base: u64,
}
/// One argument to a remote call.
@ -234,6 +273,13 @@ pub struct Scratch<'a> {
pub relocs: &'a [(usize, i64)],
}
/// How long an injected call may run before it is abandoned and the thread restored.
///
/// Generous by design: every call site here is a nullary accessor or a `this`-only query, which returns in
/// microseconds, so a second is four orders of magnitude of headroom and only a genuinely stuck callee
/// reaches it. `clean_return: false` is then the honest verdict — the same one a faulting call gets.
const CALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1);
/// Write `data` into the target at `addr`, a word at a time.
///
/// A trailing partial word is read back and merged rather than zero-filled: `PTRACE_POKEDATA` writes a
@ -413,10 +459,37 @@ pub fn call_remote_ex(
);
}
// Run, absorbing any spurious signals, until the function returns into our null trap.
// Run, absorbing any spurious signals, until the function returns into our null trap — or until
// the deadline. BOUNDED, because the alternative is unbounded: the injected callee is chosen to
// be leaf-ish, but "chosen to be" is not "proven to be", and one that blocks on a lock, a socket
// or a condition variable would park this `waitpid` forever with the tracee STOPPED — hanging a
// CI derive with no output and no timeout above it. A live check that cannot finish is a failed
// live check, not a reason to stop the release from ever being decided.
let deadline = std::time::Instant::now() + CALL_TIMEOUT;
loop {
libc::ptrace(libc::PTRACE_CONT, pid, 0usize, 0usize);
if libc::waitpid(pid, &mut status, 0) < 0 || !libc::WIFSTOPPED(status) {
// Polled rather than blocking, so the deadline is observable at all.
let waited = loop {
let r = libc::waitpid(pid, &mut status, libc::WNOHANG);
if r != 0 {
break r;
}
if std::time::Instant::now() >= deadline {
break 0;
}
std::thread::sleep(std::time::Duration::from_millis(1));
};
if waited == 0 {
// Still RUNNING, so `restore` would fail ESRCH — stop it first, then put it back exactly.
libc::kill(pid, libc::SIGSTOP);
libc::waitpid(pid, &mut status, 0);
restore(&saved);
return Ok(CallResult {
rax: 0,
clean_return: false,
});
}
if waited < 0 || !libc::WIFSTOPPED(status) {
restore(&saved);
bail!("target vanished mid-call (status {status:#x})");
}
@ -433,7 +506,6 @@ pub fn call_remote_ex(
let r = CallResult {
rax: cur.rax,
clean_return: true,
scratch_base,
};
restore(&saved);
return Ok(r);
@ -442,7 +514,6 @@ pub fn call_remote_ex(
let r = CallResult {
rax: cur.rax,
clean_return: false,
scratch_base,
};
restore(&saved);
return Ok(r);

View file

@ -17,6 +17,23 @@ use iced_x86::{Decoder, DecoderOptions, FlowControl, OpKind};
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
/// Every plausible function ENTRY in the image, sorted and deduped: relocation code-pointers (every vtable
/// slot, every stored function pointer) decoded `call` targets `.eh_frame` FDE starts.
///
/// The union is the point, and it is why this is one function rather than four lines repeated. CS2 strips
/// `.eh_frame` from the game code — the FDE list covers the statically-linked runtime tail, roughly 8,327
/// of libserver's ~70,000 functions — so an FDE-only list misses the entire gameplay region, while a
/// relocation/call-target-only list misses the runtime tail that has no code pointer taken. Six callers
/// need exactly this set: the xref index, the ConVar and VScript readers, the change digest, and both
/// anchor passes. A fork adding PLT or ifunc entries edits here, once.
pub fn function_entries(img: &CodeImage) -> Vec<u64> {
let mut entries = candidate_entries(img);
entries.extend(img.eh_frame_functions().into_iter().map(|(s, _)| s));
entries.sort_unstable();
entries.dedup();
entries
}
/// Every plausible function entry address in `img`: relocation values that point into code, plus
/// the targets of direct near `call`s found by a linear sweep. Sorted, de-duplicated.
pub fn candidate_entries(img: &CodeImage) -> Vec<u64> {

View file

@ -1,5 +1,7 @@
//! source2rosetta — CLI front-end. A thin clap layer over `source2rosetta::pipeline`: parse args,
//! select the game profile, dispatch to the engine.
//! source2rosetta — CLI front-end. A thin clap layer over BOTH engine halves —
//! `source2rosetta::pipeline` (the offline derivation engine) and `source2rosetta::produce` (CI
//! orchestration plus everything that drives a running server): parse args, select the game profile,
//! dispatch.
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
@ -53,12 +55,14 @@ enum Cmd {
/// Server library to derive from; defaults to the active game's server lib.
#[arg(long)]
lib: Option<String>,
/// Seconds to wait for the server to come up and bots to spawn alive.
/// Seconds to wait for the server to come up and reach its readiness anchor — an alive bot pawn
/// for a pawn game, a live `ready_class` instance otherwise.
#[arg(long, default_value_t = 60)]
wait: u64,
#[arg(long)] // default resolved from the active game profile at dispatch
map: Option<String>,
/// Number of bots to fill the server with.
/// Number of bots to fill the server with. A pawn-less game uses this only to size `-maxplayers`;
/// nothing waits for a bot pawn there.
#[arg(long, default_value_t = 9)]
bots: u32,
/// Optional gamedata json to also validate-live against the running server.
@ -66,23 +70,26 @@ enum Cmd {
gamedata: Option<PathBuf>,
/// Write the validated (kept) gamedata here (with --gamedata) — so this one command owns the
/// server AND persists the live-validated result, no separate validate-live needed.
#[arg(long)]
#[arg(long, requires = "gamedata")]
out: Option<PathBuf>,
/// Leave the launched server running instead of killing it after the test.
#[arg(long)]
keep: bool,
/// With --gamedata, also run the LIVE fuzzer against this same server for N randomized probes
/// (0 = off). Runs against the server `produce` already launched; there is no separate command for it.
/// (0 = off). PAWN GAMES ONLY — a pawn-less game runs no live fuzz. Runs against the server THIS
/// command launched: `integration-test` boots its own and does not attach to one `produce` left
/// behind.
#[arg(long, default_value_t = 500)]
fuzz_iterations: usize,
},
/// The whole per-game build in ONE in-memory command: derive → fold → (if `--game-dir` is given)
/// validate-live + typed netvars → fold model, writing the release set (`gamedata-`/`netvars-`/`model-`/
/// `manifest`) into --out-dir. No per-stage intermediate files. **Pass `--game-dir` for a full,
/// live-validated build; omit it for a fast OFFLINE build (gamedata + model only, no server).**
/// validate-live + typed netvars → merge → fold model, writing the release set
/// (`rosetta-<game>.json` + `model-<game>.json` + `manifest.json`) into --out-dir. No per-stage
/// intermediate files. **Pass `--game-dir` for a full, live-validated build; omit it for a fast
/// OFFLINE build (no server, so no live validation and a `null` schema).**
Produce {
/// A launchable game install → the FULL build (boots a server for validate-live + typed netvars).
/// OMIT for an offline build (gamedata + model only). The offline/full switch — no separate flag.
/// OMIT for an offline build. The offline/full switch — no separate flag.
#[arg(long = "game-dir")]
game_dir: Option<PathBuf>,
/// Dir holding the on-disk libs for make-sig + live validation (defaults to --game-dir, else --target).
@ -104,7 +111,7 @@ enum Cmd {
corpus: Option<PathBuf>,
/// Corpus-signal source B: a distilled `model-<game>.json` — forward-derives from the model + only the
/// target binary (no corpus). Also triggers the sidecar fold (model N → N+1). See --corpus.
#[arg(long)]
#[arg(long, conflicts_with = "corpus")]
corpus_model: Option<PathBuf>,
/// The build DIRECTORY to DERIVE gamedata from — the primary input (its libs are searched by name).
/// A bare `.so` path is not searched; pass the directory that contains it. REQUIRED.
@ -130,9 +137,13 @@ enum Cmd {
#[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.
/// register footprints. Static repo input — omit and no function carries a declared prototype.
#[arg(long)]
prototypes: Option<PathBuf>,
/// Authored function descriptions (`mappings/semantics-<game>.json`), folded in beside each
/// function. Static repo input, keyed on the NAME — omit and no function carries one.
#[arg(long)]
semantics: 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
@ -228,7 +239,8 @@ enum Cmd {
out: Option<PathBuf>,
},
/// Classify how much a library changed between two builds — the CI branch primitive. Enumerates every
/// function (`.eh_frame`) in each build and compares their bodies with the position-dependent bytes
/// function in each build (relocation code-pointers decoded call targets `.eh_frame` starts —
/// the FDE list alone covers ~12% of these binaries) and compares their bodies with the position-dependent bytes
/// (RIP-relative displacements + near-branch targets) masked out, so the verdict is shift-invariant:
/// a pure layout move (bodies unchanged, addresses shifted) reads as UNCHANGED, unlike a raw byte diff.
/// Prints `skip` (nothing meaningful changed → no release), `normal` (an ordinary patch → re-derive) or
@ -249,13 +261,15 @@ enum Cmd {
lib: Option<String>,
/// Extra `skip` tolerance: a changed-fraction below this also counts as `skip`. Default 0 —
/// only a code-IDENTICAL build (0 functions changed) skips, so any real patch re-derives. Raise
/// it (e.g. 0.01) to also skip changes under N%. (Calibration on 339 CS2 pairs: 311 are
/// code-identical, real patches touch <=6 functions / <=0.08%, the 2 toolchain jumps are 34%/53%.)
/// it (e.g. 0.01) to also skip changes under N%. The default is the one setting that does not
/// depend on the calibration below: zero changed functions is zero at any denominator.
#[arg(long, default_value_t = 0.0)]
skip_below: f64,
/// changed-fraction at or above this = `shift`. Default 0.20 — the CS2 corpus's real patches top
/// out near 0.08% while its two toolchain jumps are 34%/53%, so 20% cleanly separates them with
/// wide margin and (unlike 40%) doesn't misclassify the 34% jump as an ordinary patch.
/// changed-fraction at or above this = `shift`. Default 0.20. Measured over 344 CS2 builds
/// (~70,300 functions each): 82 are code-identical, the 252 ordinary patches run from 0.001% to
/// 17.8% (median 0.12%), and the 9 toolchain jumps start at 22.4% and reach 93.8%. 0.20 sits in
/// that gap — but the gap is ~4.6 points wide, not the wide margin an earlier calibration
/// claimed, so recalibrate before trusting `shift` on another game or a re-cut corpus.
#[arg(long, default_value_t = 0.20)]
shift_above: f64,
/// Emit a machine-readable JSON object instead of the human summary.
@ -277,7 +291,8 @@ enum Cmd {
/// code-identity collapses (any real change keeps the build code-distinct).
#[arg(long, default_value_t = 0.0)]
skip_below: f64,
/// changed-fraction at or above this marks a toolchain shift = an era boundary (default 0.20).
/// changed-fraction at or above this marks a toolchain shift = an era boundary (default 0.20; see
/// `classify-change --shift-above` for what that number was measured against).
#[arg(long, default_value_t = 0.20)]
shift_above: f64,
#[arg(long)]
@ -367,6 +382,7 @@ fn main() -> Result<()> {
extra_offsets,
extra_sigs,
prototypes,
semantics,
ehandle_classes,
sig_cap,
version,
@ -406,6 +422,7 @@ fn main() -> Result<()> {
extra_offsets: inputs.extra_offsets.as_deref(),
extra_sigs: inputs.extra_sigs.as_deref(),
prototypes: prototypes.as_deref(),
semantics: semantics.as_deref(),
ehandle_classes: ehandle_classes.as_deref(),
sig_cap,
version: &version,

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -56,7 +56,13 @@ impl LaunchSpec {
pub struct PawnAnchor {
pub pawn_class: &'static str, // player-pawn RTTI class — the live-oracle instance anchor
pub health_field: &'static str, // a reliable "is this instance alive" netvar
pub is_player_pawn_slot: u64, // gamedata vtable offset of IsPlayerPawn (call-live smoke test)
/// A RECORDED REFERENCE value for `IsPlayerPawn`'s vtable slot — cross-checked against, never called.
///
/// The live CALL test uses the slot THIS build derived and skips entirely when the build derived none;
/// this constant only decides whether that run prints a "the slot moved, update me" note. It is not a
/// fallback, and must not become one: the slot has taken six distinct values in ten months, and calling
/// a stale index would inject a call to whatever now occupies it. A new game may record 0 until measured.
pub is_player_pawn_slot: u64,
}
pub struct GameProfile {
@ -114,12 +120,55 @@ pub struct GameProfile {
/// test from the command one — convergence of registrar wrappers on a shared core, not a sentinel in the
/// callee — so it can fail while commands keep working.
pub min_convars: usize,
/// Floor on VScript bindings. Its own floor for the usual reason — a THIRD identification test,
/// distinct from both the command sentinel and the convar convergence: a record base computed by the
/// initialiser's own `idx*5 << 4 + [class+0x28]`. A codegen change that reshapes that arithmetic
/// yields zero bindings while every other surface keeps reading perfectly.
///
/// Set well under the observed count, which is the house rule, but the margin here is deliberately
/// wide: the reader recovers three distinct registration forms (the packed name pair, the
/// `movddup` single-string form, and a base copied between registers), and losing any ONE of them
/// would still clear a tight floor while quietly dropping a third of the surface.
pub min_vscript: usize,
/// Floor on VScript bindings attributed to an OWNING CLASS — and the only floor here that a full run
/// checks and an offline one skips, because zero is correct by construction offline: the descriptor
/// reaches its class through a register loaded from memory, so nothing static recovers it.
///
/// Separate from `min_vscript` because it fails independently and in the opposite direction. That floor
/// guards the offline READER against a Valve reshape; this one guards the LIVE WALK — the string-anchor
/// instance search, the owner read at the record's `+0x30`, the class-name read behind it. Any of those
/// breaking leaves every binding recovered, described and located, with no class on any of them: a
/// release that clears every other gate. It is `class` that `gen`'s `moddota` format GROUPS BY, so
/// the artifact would ship intact while both of the files it writes came out empty.
pub min_vscript_classed: usize,
pub min_schema_enums: usize,
/// Collapse floor for the recovered schema CLASS table — the largest table the deriver reads, and the
/// one every other schema claim rests on: the artifact's whole `schema` section, the entity-output and
/// datadesc joins, the derived type layouts, and `Identity::class_size`, which is half the identity
/// check's conjunction.
///
/// It needs its own floor because nothing else covers it. `min_schema_enums` does not — `enumerate_enums`
/// uses classes only to exclude field arrays, so it keeps passing at zero classes. The live oracle's
/// class gate does not either: it is SKIPPED below `ORACLE_MIN_SAMPLE` checked classes, and the sample
/// IS the class count, so a collapse into that range disables the check that would catch it. And the
/// offline/live layout comparison reads the same bytes through the same `CI_*` constants, so whatever
/// survives a reshape agrees with itself.
pub min_schema_classes: usize,
/// Collapse floor for the DERIVED function tiers — `core + high_confidence`.
///
/// Every table read out of the binary has one of these; the tool's headline product did not, and the
/// gap is structural rather than an oversight of one number: the live oracle gates a PASS RATE over
/// entries that reached the gamedata document, and a signature that failed to resolve never enters it.
/// So a derive that emits forty functions instead of four thousand passes at 100% — a stale corpus
/// model, a `--target` from the wrong branch or a missing secondary library all land there.
///
/// A collapse detector, not a tight bound: set well below the observed count, like every sibling floor.
pub min_core_functions: 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
/// content-dir token `csgo`/`dota` that framework formats key on). Names the artifacts
/// `gamedata-<token>.json` / `model-<token>.json` / `netvars-<token>.json`.
/// `rosetta-<token>.json` / `model-<token>.json`.
pub token: &'static str,
/// Dedicated-server launcher binary under `bin/linuxsteamrt64/` (CS2: `cs2`).
pub executable: &'static str,
@ -199,7 +248,15 @@ pub const CS2: GameProfile = GameProfile {
min_entity_classes: 200,
min_commands: 400,
min_convars: 900,
min_vscript: 180,
// observed live: 271 of 300 bindings attributed across 24 classes
min_vscript_classed: 150,
min_schema_enums: 250,
// CS2 recovers 1,899. A floor at 1,200 is well clear of build-to-build drift and nowhere near
// the range a `SchemaClassInfoData_t` reshape would leave.
min_schema_classes: 1_200,
// CS2 ships 1,086 core + 2,899 high-confidence = 3,985.
min_core_functions: 2_500,
game_key: "csgo",
token: "cs2",
executable: "cs2",
@ -304,7 +361,14 @@ pub const DOTA: GameProfile = GameProfile {
min_entity_classes: 1000,
min_commands: 400,
min_convars: 600,
min_vscript: 1200,
// observed live: 1,638 of 1,841 bindings attributed across 63 classes
min_vscript_classed: 900,
min_schema_enums: 350,
// Dota recovers 2,962.
min_schema_classes: 2_000,
// Dota ships 1,096 + 4,047 = 5,143.
min_core_functions: 3_000,
game_key: "dota",
token: "dota2",
executable: "dota2", // bin/linuxsteamrt64/dota2

View file

@ -17,13 +17,14 @@ 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";
// The provenance ids this module READS are the ones the pipeline STAMPS, imported rather than re-spelled:
// they are one fact — which evidence named the function — and two copies of it "kept in step" by a
// comment is an invariant nothing enforces. A drift there would silently stop matching, and a prototype
// that stops matching does not fail; it simply stops being claimed.
use crate::pipeline::{VALVE_CONCOMMAND, VALVE_DATADESC, VALVE_VSCRIPT};
/// What the manifest calls a prototype that came from how the ENGINE invokes the function rather than
/// from anyone's declaration of it.
/// from anyone's declaration of it. Declared here because only this module states it.
const ENGINE_CONTRACT: &str = "engine-contract";
/// The prototype the engine invokes EVERY entity-IO handler through. Kept in step with
@ -31,10 +32,6 @@ const ENGINE_CONTRACT: &str = "engine-contract";
/// 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*"];
@ -72,6 +69,62 @@ fn concommand_contract(source: &str) -> Option<Vec<String>> {
)
}
/// Read the DIRECTION of a footprint disagreement, and decide what it means.
///
/// Split out because it is the whole content of the `mismatch` verdict, and it has to be callable: the
/// tests used to re-implement this rule rather than call it, so the regression guard could only fail if
/// someone edited both copies the same wrong way. One definition, two callers.
///
/// Only an over-READ refutes a declaration. `declared_over` alone is the documented LOWER-BOUND case —
/// calling through it loads a register nobody reads, which is safe — while `measured_over` means the
/// callee reads a register the declaration never mentions, which is not. `both` stays a mismatch: a
/// class where the callee reads more is unsafe regardless of another class where it reads fewer. 81 of
/// CS2's 140 former mismatches were the safe direction, reported as "does not describe this build".
fn adjudicate_mismatch(
chosen: &Candidate,
s: &model::AbiShape,
types: Option<&BTreeMap<String, model::TypeLayout>>,
) -> (model::AbiStatus, &'static str) {
let (i, f) = footprint(chosen.params, types);
// The direction has to be read through the SAME allowance the verdict was, or the invisible `this`
// reads as an over-count on its own: `CGameEvent::GetFloat` is declared `(char const*, float)` and
// measures `int=2 float=0`, where the extra integer register is the receiver and the only real
// disagreement is the float.
let i = if chosen.complete {
i
} else {
(i..=i + 1)
.min_by_key(|d| d.abs_diff(s.int as usize))
.expect("the range always has two elements")
};
let (i, f) = (i.min(6), f.min(8));
let measured_over = s.int as usize > i || s.float as usize > f;
let declared_over = i > s.int as usize || f > s.float as usize;
let status = if declared_over && !measured_over {
model::AbiStatus::LowerBound
} else {
model::AbiStatus::Mismatch
};
let note = match (measured_over, declared_over) {
(true, true) => {
"measured and declared footprints disagree in BOTH directions, in different register \
classes: the callee reads a register the declaration does not mention AND the declaration \
passes one the callee never reads"
}
(false, true) => {
"the declaration passes registers the callee never reads, and contradicts it in no register \
class the measured footprint is a documented LOWER bound, so this is expected rather than \
evidence against the declaration"
}
(true, false) => {
"measured footprint EXCEEDS declared: the callee reads a register the declaration does not \
mention, so this declaration does not describe this build"
}
_ => "the footprints disagree in neither direction, which a mismatch cannot be",
};
(status, note)
}
/// One declared prototype as the frozen input records it.
#[derive(Deserialize)]
struct Decl {
@ -288,6 +341,7 @@ pub fn build_manifest(
prototypes: &Path,
mono: &model::Monolith,
types: Option<&BTreeMap<String, model::TypeLayout>>,
vscript_ret: Option<&BTreeMap<String, String>>,
) -> Result<model::AbiManifest> {
let doc: PrototypeDoc = serde_json::from_str(
&std::fs::read_to_string(prototypes)
@ -397,7 +451,22 @@ pub fn build_manifest(
.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 {
// The SCRIPT VM'S OWN declared return, for a name the registry states. It ranks above the
// measured register class for the reason spelled out below: a callee cannot tell whether its
// caller reads RAX, so measurement is wrong about known-void functions roughly seven times
// in eight — and `void` is what the registry declares for 849 of Dota's bindings, which is
// exactly the population measurement gets wrong. It ranks BELOW a real declaration only to
// keep "a source wrote this down" ahead of anything derived; in practice the two never
// compete, because no VScript name is also a declared name (measured: zero overlap).
//
// Read BEFORE the gate below, not after: a registry-declared return is on its own enough to
// have something to say about a function, so a name carrying one must not be skipped for
// having no parameter list. That is precisely the `return-only` case.
let vs_ret = vscript_ret.and_then(|m| m.get(name)).cloned();
let has_vs_ret = vs_ret.is_some();
if decls.is_empty() && !is_contract && !has_vs_ret {
bump(&format!("{tier}:none"));
continue;
}
@ -413,6 +482,7 @@ pub fn build_manifest(
any_ret
.clone()
.or(contract)
.or(vs_ret)
.or_else(|| sh.map(|s| s.ret.clone()))
};
let mut provenance: Vec<String> = decls
@ -424,6 +494,9 @@ pub fn build_manifest(
if is_contract {
provenance.push(ENGINE_CONTRACT.to_string());
}
if has_vs_ret {
provenance.push(VALVE_VSCRIPT.to_string());
}
// The contract goes in FIRST, so that where it and a declaration both fit the measurement,
// `most_specific` reports the one that names its receiver — which the contract always does
@ -440,8 +513,14 @@ pub fn build_manifest(
});
}
collect_candidates(decls, &mut cands);
// `bare-name` is a CLAIM — "one declaration bears this method name and the measurement could
// adjudicate" — so it must not be the fallback for an entry that was never name-matched at
// all. A registry-declared return with no declaration behind it is neither exact nor
// bare-name; it is the script VM stating its own contract, and it says so.
let matched_by = if exact.is_some() {
"exact"
} else if decls.is_empty() && has_vs_ret {
VALVE_VSCRIPT
} else {
"bare-name"
};
@ -567,46 +646,9 @@ pub fn build_manifest(
// 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(),
);
let (verdict, why) = adjudicate_mismatch(&chosen, s, types);
status = verdict;
note = Some(why.to_string());
}
// A BARE-NAME claim that the measurement CONTRADICTS is withdrawn, not reported. The gate
// admits a bare name only when a measurement exists to adjudicate it — and adjudicating
@ -645,6 +687,9 @@ pub fn build_manifest(
note,
overloads: (cands.len() > 1).then_some(all_sigs),
vtable,
// Prose belongs to the function record, not to a prototype; the merge attaches it
// there and `Rosetta::abi_manifest` joins it back on for the emitters.
doc: None,
},
);
}
@ -834,36 +879,28 @@ mod tests {
}
/// The verdict AND the note, for one declaration against one measurement.
/// CALLS the shipped rule rather than restating it. It used to re-implement `build_manifest`'s
/// direction logic, which made the assertions below unfalsifiable: only an edit that changed both
/// copies the same wrong way could fail them, and that is the one edit nobody makes by accident.
/// The direction is read back out of the shipped note text, so the mapping from direction to prose
/// is under test too.
fn judge(params: &[&str], sh: &model::AbiShape, complete: bool) -> (model::AbiStatus, String) {
let ps = p(params);
let c = cand(&ps, complete);
if agrees(&c, sh, None) {
return (model::AbiStatus::Verified, String::new());
}
let (i, f) = footprint(c.params, None);
let i = if complete {
i
let (status, note) = adjudicate_mismatch(&c, sh, None);
let direction = if note.starts_with("measured and declared") {
"both"
} else if note.starts_with("measured footprint EXCEEDS") {
"measured-exceeds"
} else if note.starts_with("the declaration passes") {
"declared-exceeds"
} else {
(i..=i + 1)
.min_by_key(|d| d.abs_diff(sh.int as usize))
.unwrap()
"neither"
};
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(),
)
(status, direction.to_string())
}
#[test]

View file

@ -26,7 +26,7 @@
use crate::elf::CodeImage;
use iced_x86::{Decoder, DecoderOptions, FlowControl, Instruction, Mnemonic, OpKind, Register};
use std::collections::{BTreeMap, HashMap};
use std::collections::{BTreeMap, HashMap, HashSet};
/// 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
@ -101,23 +101,22 @@ 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.
/// Every instruction address reachable from `entry` inside `[entry, entry+code.len())`, in ADDRESS order.
///
/// 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();
/// One walk, two callers: the descriptor trace and the shim's liveness read need exactly the same thing —
/// flow-reachable addresses rather than a linear sweep, so a jump table or an interleaved neighbour cannot
/// contribute instructions the function never executes. They differ only in how far they are willing to
/// walk, which is the `cap`.
///
/// `cap` bounds the SET, not the span: a crafted image can present a small span with pathological branch
/// density, and this is on the fuzz surface.
fn reachable(code: &[u8], entry: u64, cap: usize) -> Vec<u64> {
let end = entry.saturating_add(code.len() as u64);
let mut seen: HashSet<u64> = HashSet::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 {
if at < entry || at >= end || seen.contains(&at) || seen.len() > cap {
continue;
}
let mut dec =
@ -129,7 +128,7 @@ fn trace(img: &CodeImage, entry: u64, seed_rdi: Option<u64>) -> Option<Trace> {
if insn.is_invalid() || insn.len() == 0 {
continue;
}
seen.insert(at, insn.len());
seen.insert(at);
match insn.flow_control() {
FlowControl::Return
| FlowControl::IndirectBranch
@ -143,9 +142,22 @@ fn trace(img: &CodeImage, entry: u64, seed_rdi: Option<u64>) -> Option<Trace> {
_ => work.push(at + insn.len() as u64),
}
}
let mut addrs: Vec<u64> = seen.keys().copied().collect();
let mut addrs: Vec<u64> = seen.into_iter().collect();
addrs.sort_unstable();
addrs
}
/// 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 all = img.code_at(entry)?;
let code = &all[..all.len().min(MAX_SPAN)];
let mut insn = Instruction::default();
let addrs = reachable(code, entry, 4000);
let mut out = Trace::default();
let mut regs: HashMap<Register, u64> = HashMap::new();
@ -606,6 +618,22 @@ impl ShimReads {
}
}
/// Where an accessor's descriptor region LIVES, and how many elements it holds: `(base, count)`.
///
/// The signature reader reconstructs the region's CONTENTS by constant-propagating the initialiser,
/// because on disk the elements are zeroes — they are written at runtime. That reconstruction is the
/// only offline route, and it is also unverified: the multi-library duplicate check reports that CS2
/// disagrees with itself on 331 of 419 repeat registrations, and nothing offline can say which account
/// is right.
///
/// A running server can. The region is a plain static, so at `slide + base` a live process holds the
/// POPULATED elements, and reading them settles the question against the same build rather than against
/// a dump of a different one. This accessor exists for that oracle; the derivation itself never needs it.
pub fn record_region(img: &CodeImage, accessor: u64) -> Option<(u64, u64)> {
let r = record(img, accessor)?;
(r.base != 0).then_some((r.base, r.count))
}
/// Measure which of a shim's seven arguments it reads.
///
/// Reachable instructions in ADDRESS order, which needs two guards that cost real time to find:
@ -618,46 +646,9 @@ impl ShimReads {
/// argument live that the shim never consumes; `xor edi, edi` alone accounted for 143 false positives.
pub fn shim_reads(img: &CodeImage, entry: u64) -> Option<ShimReads> {
let all = img.code_at(entry)?;
let extent = (all.len() as u64).min(SHIM_SPAN);
let code = &all[..extent as usize];
// Saturating: `extent` derives from the section length, so on a crafted image `entry + extent` can
// wrap and turn the span test inside out — and the fuzz harness builds with overflow checks, where a
// plain add aborts. The same shape `fuzz_concmd` was written for.
let end = entry.saturating_add(extent);
let in_span = |t: u64| t >= entry && t < end;
let mut seen: HashMap<u64, ()> = HashMap::new();
let mut work = vec![entry];
let code = &all[..(all.len() as u64).min(SHIM_SPAN) as usize];
let mut insn = Instruction::default();
while let Some(at) = work.pop() {
if seen.contains_key(&at) || !in_span(at) || seen.len() > 20000 {
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, ());
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 addrs = reachable(code, entry, 20000);
let mut live: BTreeMap<Register, bool> = BTreeMap::new();
let mut sink = false;

View file

@ -18,11 +18,9 @@ pub struct VTable {
/// One vtable discovered by the whole-binary sweep — the class inventory row.
pub struct ClassVtable {
pub mangled: String, // the raw `_ZTS` type name, e.g. "11CBaseEntity"
pub name: String, // demangled, e.g. "CBaseEntity"
pub vtable_va: u64, // vaddr of slot index 0
pub offset_to_top: i64, // 0 for the primary (complete-object) vtable; <0 for sub-object tables
pub typeinfo: u64, // vaddr of the Itanium typeinfo struct
pub slots: Vec<u64>, // method vaddrs; a method's gamedata offset == its index here
pub bases: Vec<BaseClass>, // direct base classes (the is-a graph edges)
}
@ -143,10 +141,10 @@ fn demangle_type(mangled: &str) -> String {
.unwrap_or_else(|| mangled.to_string())
}
/// If `ti` addresses a valid Itanium typeinfo, return its `(mangled, demangled)` class name.
/// If `ti` addresses a valid Itanium typeinfo, return its DEMANGLED class name.
/// A typeinfo is `[kind_vtable_ptr][name_ptr][ base-class data … ]`: `+0` points at one of the
/// C++ runtime's type_info-kind vtables, `+8` at the `_ZTS` name string.
fn typeinfo_name(img: &CodeImage, ti: u64, kinds: &RttiKinds) -> Option<(String, String)> {
fn typeinfo_name(img: &CodeImage, ti: u64, kinds: &RttiKinds) -> Option<String> {
// +0 must be one of the three kind vtables. Prefer the symbol-name-derived tag (the only signal that
// survives a DYNAMICALLY-linked C++ runtime, where the three kinds all resolve to the same offline
// value); else fall back to the in-image value check (statically-linked / stripped builds).
@ -163,7 +161,7 @@ fn typeinfo_name(img: &CodeImage, ti: u64, kinds: &RttiKinds) -> Option<(String,
if !(c0.is_ascii_digit() || matches!(c0, b'N' | b'I' | b'P' | b'K' | b'S')) {
return None;
}
Some((mangled.clone(), demangle_type(&mangled)))
Some(demangle_type(&mangled))
}
/// Direct base classes of the typeinfo at `ti`, dispatched on its exact Itanium kind.
@ -187,7 +185,7 @@ fn typeinfo_bases(img: &CodeImage, ti: u64, kinds: &RttiKinds) -> Vec<BaseClass>
Some(KindTag::Si) => {
// __si_class_type_info: one public, non-virtual base at offset 0; its typeinfo ptr at +16.
if let Some(bp) = img.read_ptr(ti.wrapping_add(16))
&& let Some((_, name)) = typeinfo_name(img, bp, kinds)
&& let Some(name) = typeinfo_name(img, bp, kinds)
{
return vec![BaseClass {
name,
@ -199,7 +197,7 @@ fn typeinfo_bases(img: &CodeImage, ti: u64, kinds: &RttiKinds) -> Vec<BaseClass>
}
Some(KindTag::Vmi) => {
// __vmi_class_type_info: flags@+16, base_count@+20, then 16-byte {typeinfo_ptr, offset_flags}.
let Some(count) = img.read_u32(ti + 20) else {
let Some(count) = img.read_u32(ti.wrapping_add(20)) else {
return Vec::new();
};
if count == 0 || count > 128 {
@ -211,7 +209,7 @@ fn typeinfo_bases(img: &CodeImage, ti: u64, kinds: &RttiKinds) -> Vec<BaseClass>
let Some(bp) = img.read_ptr(e) else {
break;
};
if let Some((_, name)) = typeinfo_name(img, bp, kinds) {
if let Some(name) = typeinfo_name(img, bp, kinds) {
let of = img.read_i64(e.wrapping_add(8)).unwrap_or(0);
bases.push(BaseClass {
name,
@ -239,7 +237,7 @@ pub fn enumerate_vtables(img: &CodeImage, max_slots: usize) -> Vec<ClassVtable>
if slot < 8 {
continue;
}
let Some((mangled, name)) = typeinfo_name(img, val, &kinds) else {
let Some(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
@ -259,11 +257,9 @@ pub fn enumerate_vtables(img: &CodeImage, max_slots: usize) -> Vec<ClassVtable>
}
let bases = typeinfo_bases(img, val, &kinds);
out.push(ClassVtable {
mangled,
name,
vtable_va,
offset_to_top: ott,
typeinfo: val,
slots,
bases,
});

View file

@ -60,6 +60,10 @@ pub const CURRENT_LAYOUT: SchemaLayout = SchemaLayout {
ci_base_count: 41,
ci_fields: 48,
ci_bases: 56,
// Every displacement below is added to a FILE-CONTROLLED pointer, so each use wraps rather than
// panicking under the overflow-checked fuzz build. That includes the ones that are 0 today: they are
// layout values, revised when Valve reshapes the struct, and "safe because this constant happens to
// be zero" is a trap that springs on the revision rather than on the code that introduced it.
f_name: 0,
f_offset: 16,
f_stride: 32,
@ -157,7 +161,10 @@ pub fn enumerate_schema(img: &CodeImage) -> Vec<SchemaClass> {
out.push(cls);
}
}
out.sort_by(|a, b| a.name.cmp(&b.name));
// By NAME, then by the record's own address. The walk iterates `reloc_slots()`, which is a HashMap,
// so the collection order is hash order; a sort on name alone is stable and therefore leaves ties —
// two libraries registering one class — resolved by that hash order, in a byte-reproducible artifact.
out.sort_by(|a, b| (&a.name, a.class_info).cmp(&(&b.name, b.class_info)));
out
}
@ -185,11 +192,49 @@ const EV_VALUE: u64 = 8;
/// 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> {
/// Enumerate every registered enum in `img`, given the classes [`enumerate_schema`] already recovered.
/// 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.
///
/// **A class's FIELD descriptor is byte-compatible with an enum binding**, which is why `classes` is a
/// parameter rather than a convenience. `SchemaClassFieldData_t` is `{ name, type, offset, metadataCount,
/// metadata }`: read as an enum binding, the name reads as a type name, the low bytes of the offset read
/// as a plausible size/alignment, the metadata count reads as an enumerator count, and the metadata array
/// — `{ name, data }` pairs — reads as enumerators. Every field carrying exactly one metadata tag at a
/// field offset whose low two bytes are both powers of two therefore fits, and the result would be an
/// enum that does not exist, named after a member, whose one "value" is the ADDRESS of a documentation
/// string and therefore differs between runs of the same build.
///
/// Two independent structural facts reject them, and both are needed — measured over 1,016 CS2 and 1,490
/// Dota candidates, they catch 40 apiece with zero real enums lost, and neither catches all 40 alone:
///
/// 1. **The record sits inside a class's field array**, at a `F_STRIDE` boundary. That is not a heuristic
/// — the SchemaSystem states that this address is that class's Nth field.
/// 2. **An enumerator's value is a relocation.** An enum value is a compile-time literal, so it is never
/// relocated; a metadata entry's second word is a pointer, so it always is. This is what catches a
/// field whose owning class the class walk itself rejected, leaving no array to fall inside.
pub fn enumerate_enums(img: &CodeImage, classes: &[SchemaClass]) -> Vec<SchemaEnum> {
// The address ranges class field descriptors occupy, sorted so membership is a binary search.
let mut spans: Vec<(u64, u64)> = classes
.iter()
.filter_map(|c| {
let fp = img.read_ptr(c.class_info.wrapping_add(CI_FIELDS))?;
(fp != 0).then(|| (fp, fp.wrapping_add(F_STRIDE * c.fields.len() as u64)))
})
.collect();
spans.sort_unstable();
// Each class owns its own array, so the ranges are disjoint and the last one starting at or before
// `a` is the only one that can contain it. If that ever stopped holding, the miss would be a fake
// enum surviving rather than a real one dropped — the same direction every other guard here errs in.
let in_field_array = |a: u64| {
let i = spans.partition_point(|&(s, _)| s <= a);
i > 0 && {
let (s, e) = spans[i - 1];
a < e && (a - s).is_multiple_of(F_STRIDE)
}
};
let mut out = Vec::new();
for (slot, val) in img.reloc_slots() {
let Some(name) = img.read_c_string(val) else {
@ -201,6 +246,10 @@ pub fn enumerate_enums(img: &CodeImage) -> Vec<SchemaEnum> {
continue;
}
let base = slot.wrapping_sub(EB_TYPE_NAME);
// Clause 1: the SchemaSystem states this address is a class's field descriptor, so it is one.
if in_field_array(base) {
continue;
}
let Some(w) = img.read_ptr(base.wrapping_add(EB_WIDTH)) else {
continue;
};
@ -228,7 +277,10 @@ pub fn enumerate_enums(img: &CodeImage) -> Vec<SchemaEnum> {
) else {
break;
};
if n.is_empty() {
// Clause 2: a relocated "value" is a pointer, so these are `{ name, data }` metadata
// entries and not enumerators. Rejects the whole record — one pointer among the values
// means the array is the wrong kind, not that one enumerator is odd.
if n.is_empty() || img.is_reloc_slot(rec.wrapping_add(EV_VALUE)) {
break;
}
values.push((n, v));
@ -242,7 +294,13 @@ pub fn enumerate_enums(img: &CodeImage) -> Vec<SchemaEnum> {
});
}
}
out.sort_by(|a, b| a.name.cmp(&b.name));
// Same reason as `enumerate_schema`, and it matters MORE here because the `dedup_by` below then keeps
// whichever row sorted first: with a name-only sort that survivor was picked by hash order. Enums have
// no record address on the struct, so the tiebreak is the content that distinguishes two accounts of
// one name — width, alignment, then the enumerator list.
out.sort_by(|a, b| {
(&a.name, a.size, a.align, &a.values).cmp(&(&b.name, b.size, b.align, &b.values))
});
out.dedup_by(|a, b| a.name == b.name); // one binding per name; libs re-register shared enums
out
}
@ -272,10 +330,13 @@ fn parse_class(img: &CodeImage, base: u64, name: &str, name_ptr: u64) -> Option<
let mut fields = Vec::with_capacity(field_count as usize);
for i in 0..field_count as u64 {
let fe = fields_ptr.wrapping_add(i.wrapping_mul(F_STRIDE));
let Some(fname) = img.read_ptr(fe + F_NAME).and_then(|p| img.read_c_string(p)) else {
let Some(fname) = img
.read_ptr(fe.wrapping_add(F_NAME))
.and_then(|p| img.read_c_string(p))
else {
break;
};
let offset = img.read_i32(fe + F_OFFSET).unwrap_or(0);
let offset = img.read_i32(fe.wrapping_add(F_OFFSET)).unwrap_or(0);
fields.push(SchemaField {
name: fname,
offset,
@ -288,13 +349,13 @@ fn parse_class(img: &CodeImage, base: u64, name: &str, name_ptr: u64) -> Option<
if bases_ptr != 0 {
for i in 0..base_count as u64 {
let be = bases_ptr.wrapping_add(i.wrapping_mul(B_STRIDE));
let offset = img.read_u32(be + B_OFFSET).unwrap_or(0);
let bcls = img.read_ptr(be + B_CLASS).unwrap_or(0);
let offset = img.read_u32(be.wrapping_add(B_OFFSET)).unwrap_or(0);
let bcls = img.read_ptr(be.wrapping_add(B_CLASS)).unwrap_or(0);
if bcls == 0 {
continue;
}
if let Some(bn) = img
.read_ptr(bcls + CI_NAME)
.read_ptr(bcls.wrapping_add(CI_NAME))
.and_then(|p| img.read_c_string(p))
{
bases.push(SchemaBase { name: bn, offset });
@ -316,7 +377,7 @@ fn parse_class(img: &CodeImage, base: u64, name: &str, name_ptr: u64) -> Option<
// The offline reader above recovers class layouts (names + field offsets) from the static reflection
// tables. Field *types* are runtime-resolved (each record's `m_pType` is a null pointer on disk), so
// `live_schema` attaches to a running process, reads the types back, and builds the typed
// `netvars-<game>.json` (`model::Schema`) directly — no `sdk.json` intermediate.
// the artifact's `schema` section (`model::Schema`) directly — no `sdk.json` intermediate.
/// FNV-1a (32-bit). The Source-2 schema field/class name hash: a field's runtime lookup key is
/// `(fnv1a32(class_name) << 32) | fnv1a32(field_name)` (field name keeps its `m_` prefix). Confirmed
@ -344,7 +405,7 @@ fn builtin_size(t: &str) -> i32 {
/// Walk a running process's schema across every server-mapped library (`profile.libs`) and build the typed
/// netvars (`model::Schema`) DIRECTLY — no `sdk.json` round-trip: field layout is read offline from each
/// image, the runtime `m_pType` from the live process. Shared classes (compiled into many libs) de-dupe
/// precedence-first (the earlier lib in `libs` wins). This is `netvars-<game>.json` — the shipped SDK
/// precedence-first (the earlier lib in `libs` wins). This is the artifact's `schema` section — the shipped SDK
/// material (`source2rosetta-gen` renders it on demand).
pub(crate) fn live_schema(
prof: &GameProfile,
@ -370,9 +431,13 @@ pub(crate) fn live_schema(
};
let Some(base) = live.base(lib) else { continue }; // lib not mapped in the process -> skip
nlibs += 1;
// ONE class walk per library, shared by both consumers below — the enum walk needs it to tell a
// field descriptor from an enum binding, and walking the reflection tables twice per image is
// what a large game's memory ceiling notices first.
let schema_classes = enumerate_schema(&img);
// 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) {
for e in enumerate_enums(&img, &schema_classes) {
enums.entry(e.name).or_insert_with(|| model::EnumDef {
size: e.size,
values: e
@ -382,7 +447,7 @@ pub(crate) fn live_schema(
.collect(),
});
}
for c in &enumerate_schema(&img) {
for c in &schema_classes {
// a shared class already taken from an earlier (higher-precedence) lib — identical layout, skip
if !seen.insert(c.name.clone()) {
continue;

View file

@ -51,8 +51,13 @@ pub struct PulseBinding {
///
/// Measured as a fixed-signature marshalling stub: seven integer arguments returning int, where the
/// fifth is an array of pointers to the argument values (element *k* at `+8+8k`) and the seventh is an
/// output sink read by exactly the bindings that declare a return. Not emitted into any artifact — the
/// contract has not been validated by an actual call, and a locator nobody has exercised is a claim.
/// output sink read by exactly the bindings that declare a return.
///
/// It IS emitted, and the measurement above is not the reason to trust it — the live oracle is. The
/// address and its measured read-set ship as `surfaces.pulse[].shim` / `.call`; `verify_pulse_shims`
/// actually CALLS every `args-only` shim on a running server each build (CS2 186 of 193 clean, Dota
/// 211 of 211); and `GameProfile::min_pulse_callable` floors the population that survives. A locator
/// nobody has exercised would be a claim — this one is exercised every derive.
pub shim: u64,
pub flags: PulseFlags,
}

558
src/vscript.rs Normal file
View file

@ -0,0 +1,558 @@
//! The VScript binding registry — the fourth surface a Source-2 module documents about itself, and the
//! only one that states a function's PARAMETER NAMES.
//!
//! Valve exposes a subset of the C++ surface to script (Lua in Dota's custom games, and a smaller set in
//! CS2). Every exposed method is registered with the script VM through a descriptor carrying its
//! script-facing name, its C++ name, an English description, a return type, and a pointer to the
//! implementation. That is a locator AND a prototype AND documentation, all stated by Valve, which makes
//! it the same kind of find as the Pulse registry and the console-command registration.
//!
//! # Why this is not a table walk
//!
//! The obvious route — find a static array of descriptors and read it — does not work, and the reason is
//! worth stating because it costs a day to rediscover. The descriptors are built at RUNTIME: a scan of
//! Dota's `libserver.so` finds 2,268,664 `R_X86_64_RELATIVE` relocations and **not one** points at a
//! description string. On disk the descriptor array is zeroes.
//!
//! What is static is the CODE that fills it in, and every field is a constant in the instruction stream.
//! This is the same shape the Pulse parameter records turned out to have, and the same answer applies:
//! constant-propagate through the initialiser rather than read the table.
//!
//! ```text
//! movq xmm0, [rip+slot] ; the script-facing name, via a relocated .data.rel.ro slot
//! lea rdx, [rip+"Script_TakeDamage"]
//! pinsrq xmm0, rdx, 1 ; pack both names into one 16-byte store
//! lea rsi, [rip+"Applies damage to this entity."]
//! lea rax, [rax+rax*4] ; index * 5
//! shl rax, 4 ; * 16 -> stride 80
//! add rax, [rbx+0x28] ; base = owning class descriptor's function array
//! mov [rax+0x30], rbx ; owner
//! mov [rax+0x10], rsi ; description
//! movups [rax], xmm0 ; +0x00 script name, +0x08 C++ name
//! mov [rax+0x18], r11w ; return type
//! ```
//!
//! # The record
//!
//! **80 bytes**, derived rather than assumed — the `lea r,[r+r*4]` / `shl r,4` pair states it in the
//! instruction stream, so a stride change is a decode failure rather than silent corruption.
//!
//! | offset | field |
//! |---|---|
//! | `+0x00` | script-facing name (`TakeDamage`) |
//! | `+0x08` | C++ binding name (`Script_TakeDamage`) |
//! | `+0x10` | Valve's English description |
//! | `+0x18` | return type, a `u16` |
//! | `+0x28` | a name string — the return value's, where one is given |
//! | `+0x30` | the owning class descriptor |
//! | `+0x38` | the marshalling thunk, SHARED by every binding of the same shape |
//! | `+0x40` | pointer-to-member: the implementation |
//! | `+0x48` | a `u32` count |
//!
//! `+0x40` is an Itanium pointer-to-member, which is convenient rather than awkward: a non-virtual
//! member is a plain address and a virtual one is `slot * 8 + 1`. Those are exactly the two locator
//! forms the rest of this crate already emits, so a VScript binding lands in `gamedata` as either a
//! signature or a vtable offset with no new concept.
//!
//! Do not confuse `+0x38` with `+0x40`. The thunk at `+0x38` is a compiler-generated trampoline shared
//! across every binding with the same signature; folding it would ship dozens of distinct names all
//! pointing at one address. That is the same mistake the Pulse `+24`/`+32` accessors invite, and it is
//! caught here the same way — by the sharing itself, since a real implementation is referenced once.
//!
//! # Shape-driven, so a layout change yields FEWER bindings and never wrong ones
//!
//! Nothing here is anchored on an address, a symbol or a fixed offset into the image. A record is
//! recognised by what the initialiser DOES — a 16-byte store of two plausible name strings, a
//! description or nothing at `+0x10`, a small return type, and a `+0x40` that is either executable code
//! or a small odd integer. A build that reshapes the descriptor fails those tests and produces nothing,
//! which the release floor then catches.
use crate::elf::CodeImage;
use iced_x86::{Decoder, DecoderOptions, FlowControl, Instruction, Mnemonic, OpKind, Register};
use std::collections::{BTreeMap, HashMap};
/// The record stride, in bytes. Stated by the initialiser's own `idx*5 << 4`; kept as a constant only to
/// validate what is decoded.
///
/// `pub(crate)` because the LIVE class walk in `produce` steps the same records and must step them by
/// the same number. Unlike the Pulse element stride this one is NOT derived by consensus — the
/// initialiser states it in the instruction stream, so there is nothing to vote on — and a build that
/// changes it shows up as decoded records failing validation here, not as a mis-strided live walk.
pub(crate) const STRIDE: i64 = 80;
/// Field displacements within the record.
const F_NAME: i64 = 0x00;
const F_CPP: i64 = 0x08;
const F_DESC: i64 = 0x10;
const F_RET: i64 = 0x18;
const F_IMPL: i64 = 0x40;
/// Longest accepted name/description, so a mis-decoded pointer into the middle of a blob cannot produce a
/// megabyte "name".
const MAX_NAME: usize = 128;
const MAX_DESC: usize = 512;
/// `ScriptDataType_t`, DERIVED by joining recovered bindings against Valve's own published VScript dump
/// rather than assumed from Source's historical ordering.
///
/// The distinction matters, and the first attempt at this table is the reason it is spelled out. Two
/// anchors were available by inspection — a binding returning `float` stores `1`, one returning `int`
/// stores `5` — and they fit Source 1's long-standing `FIELD_*` ordering, in which `5` is `BOOLEAN`. That
/// reading was WRONG: joined against 389 bindings whose return type Valve states, `5` is `int` and `6` is
/// `bool`. Two points are enough to fit a plausible table and not enough to check one.
///
/// Agreement on the derived table is total where a comparison is meaningful. The apparent disagreements
/// are Valve naming a SEMANTIC type over the same ABI type: `5` also covers `modifierpriority` and
/// `UnitFilterResult` (enums, which are ints), and `31` also covers `CDOTA_BaseNPC` and `CBaseEntity`
/// (entity handles, which are handles).
///
/// The raw word ships beside the decoded name regardless — the rule `flags_raw` already follows — so a
/// build that renumbers this can be re-read rather than silently mislabelled.
const RET_TYPES: [(u16, &str); 13] = [
(0, "void"),
(1, "float"),
(3, "Vector"),
(5, "int"),
(6, "bool"),
(13, "ehandle"),
(14, "Vector"),
(29, "unknown"),
(30, "string"),
(31, "handle"),
(32, "table"),
(37, "uint"),
(39, "QAngle"),
];
/// Decode a return-type word, or `None` when the value is outside what is corroborated.
pub fn ret_type_name(raw: u16) -> Option<&'static str> {
RET_TYPES.iter().find(|(v, _)| *v == raw).map(|(_, n)| *n)
}
/// Where a binding's implementation lives, decoded from the pointer-to-member at `+0x40`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Impl {
/// A non-virtual member: the address itself.
Addr(u64),
/// A virtual member: `(pmf - 1) / 8` is the vtable slot index.
Slot(u64),
}
/// One registered VScript binding.
#[derive(Clone, Debug)]
pub struct VScriptFunc {
/// The script-facing name a Lua author calls (`TakeDamage`).
pub name: String,
/// The C++ binding name (`Script_TakeDamage`). Often but not always the script name with a prefix.
pub cpp_name: String,
/// Valve's own English description, where the registration supplies one.
pub description: Option<String>,
/// The return type as stored, undecoded.
pub ret_raw: u16,
/// The return type decoded, or `None` if the value is outside the corroborated set.
pub ret: Option<&'static str>,
/// The implementation, as an address or a vtable slot.
pub imp: Option<Impl>,
}
/// What a register provably holds — CONSTANTS only, which is where this parts company with `concmd`'s
/// tracker.
///
/// There is no symbolic-base variant here and none is needed: a store is credited to a record through
/// `recid`, propagated across `mov rD,rS`, rather than through a `(register, epoch)` pair. That is why
/// the base survives being copied between registers, and why this tracker needs no epoch counter.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum V {
Unknown,
Const(u64),
}
impl V {
fn konst(self) -> Option<u64> {
match self {
V::Const(c) => Some(c),
V::Unknown => None,
}
}
}
/// The 64-bit parent register as a slot index. Thin wrapper over [`crate::abi::gp_slot`] — the mapping is a
/// fixed SysV fact, and this file only narrows it to the `u8` its `[_; 16]` arrays index by.
fn gpr(r: Register) -> Option<u8> {
crate::abi::gp_slot(r).map(|s| s as u8)
}
fn xmm(r: Register) -> Option<u8> {
r.is_xmm()
.then(|| (r as usize - Register::XMM0 as usize) as u8)
.filter(|i| *i < 16)
}
/// Registers a call clobbers, so a value cannot survive across one and be attributed to the wrong record.
const CLOBBER: [usize; 9] = [0, 1, 2, 6, 7, 8, 9, 10, 11];
/// A field of a particular record: which record, and the displacement within it.
type Slot = (u32, i64);
/// Read a NUL-terminated string, rejecting anything that is not plausibly a name.
fn text(img: &CodeImage, va: u64, max: usize) -> Option<String> {
let s = img.read_c_string(va)?;
if s.is_empty() || s.len() > max {
return None;
}
s.chars()
.all(|c| c.is_ascii_graphic() || c == ' ')
.then_some(s)
}
/// An identifier-shaped string — what a script-facing or C++ name must look like. Deliberately strict:
/// a mis-decoded pointer usually lands on prose or a path, and both fail this.
fn ident(img: &CodeImage, va: u64) -> Option<String> {
let s = text(img, va, MAX_NAME)?;
let ok = s
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == ':')
&& s.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_');
ok.then_some(s)
}
/// Decode the pointer-to-member at `+0x40`.
///
/// The two forms are distinguished by the low bit, per the Itanium ABI. Both are validated: an address
/// has to land in executable code, and a slot index has to be small enough to be a real vtable position.
/// Anything else means the field is not what this reader thinks it is, and yields `None` rather than a
/// confident wrong locator.
fn decode_pmf(img: &CodeImage, pmf: u64) -> Option<Impl> {
if pmf == 0 {
return None;
}
if pmf & 1 == 1 {
let slot = (pmf - 1) / 8;
// 2048 is the same ceiling `rtti` reads vtables to; past it this is not a slot index.
(slot < 2048 && (pmf - 1).is_multiple_of(8)).then_some(Impl::Slot(slot))
} else {
img.is_code(pmf).then_some(Impl::Addr(pmf))
}
}
/// Recover every VScript binding the image registers.
///
/// One pass over the candidate functions, tracking what each register and XMM half provably holds and
/// collecting stores to record-relative slots. A group of stores is accepted as a binding only if it
/// presents the full shape, so partial or coincidental matches are dropped rather than guessed at.
pub fn vscript_functions(img: &CodeImage) -> Vec<VScriptFunc> {
let entries = crate::locate::function_entries(img);
let mut out: Vec<VScriptFunc> = Vec::new();
let mut insn = Instruction::default();
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;
};
let mut val = [V::Unknown; 16];
// Which registers currently hold a RECORD BASE, and which hold the half-built `idx*80` on the way
// to one. This is the structural anchor: a store is only collected when its base was computed by
// the initialiser's own `idx*5 << 4 + [class+0x28]`. Without it the pass collects any struct with
// two string pointers at +0x00/+0x08, and `libserver` has at least one other table of that shape
// (the network field serialisers) which then contributes records whose "name" is a netvar.
let mut scaled = [false; 16];
// Which RECORD each register currently points at, not merely whether it points at one. Keying on
// identity rather than on (register, epoch) is what lets a base survive `mov rcx,rax` — the
// compiler routinely copies the base and then reuses the original for something else, writing
// half a record through each. Keyed by register, those two halves land in different groups and
// neither is complete.
let mut recid: [Option<u32>; 16] = [None; 16];
let mut next_rec: u32 = 0;
// Each XMM tracked as its two 64-bit halves, which is the only way the packed name store is
// readable: both names reach the record through one 16-byte write.
let mut xr = [(V::Unknown, V::Unknown); 16];
let mut stores: HashMap<Slot, u64> = HashMap::new();
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 {
for c in CLOBBER {
val[c] = V::Unknown;
scaled[c] = false;
recid[c] = None;
}
xr = [(V::Unknown, V::Unknown); 16];
// MEASURED DEAD END, recorded so it is not re-attempted: treating `rax` as a speculative
// record base after every call — on the theory that some registrations allocate a record
// and fill it through the returned pointer — reintroduces precisely the network-field
// serialisers the record-base anchor exists to reject (`CBaseEntity`/`m_fFlags`,
// `CNetworkOriginCellCoordQuantizedVector`/`m_cellX`, the `*ChangedCompat` callbacks) and
// recovers no additional binding. The structure built through a call's return here is the
// CLASS descriptor, not a function record: its `+0x00`/`+0x08` hold the class name twice.
continue;
}
match insn.mnemonic() {
// `lea r,[rip+d]` — a string or global address. `lea rD,[rS+rS*4]` is something else
// entirely: the first half of the record-base computation, `idx * 5`.
Mnemonic::Lea => {
if let Some(d) = gpr(insn.op0_register()) {
let times_five = insn.memory_index() != Register::None
&& insn.memory_base() == insn.memory_index()
&& insn.memory_index_scale() == 4
&& insn.memory_displacement64() == 0;
val[d as usize] = if insn.is_ip_rel_memory_operand() {
V::Const(insn.ip_rel_memory_address())
} else {
V::Unknown
};
scaled[d as usize] = times_five;
recid[d as usize] = None;
}
}
// `xor rD,rD` is the zeroing idiom, not an arithmetic unknown. It matters more here than
// it looks: a `void` binding sets its return type with `xor r11d,r11d` and then stores
// `r11w`, so treating this as an unknown loses every void-returning binding — which on
// Dota is most of them.
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 };
scaled[d as usize] = false;
recid[d as usize] = None;
}
}
// `shl rD,4` completes `idx * 80`. Any other shift of a scaled register means this is
// not the idiom and the candidate is dropped.
Mnemonic::Shl => {
if let Some(d) = gpr(insn.op0_register()) {
let keep = scaled[d as usize]
&& insn.op1_kind() == OpKind::Immediate8
&& insn.immediate8() == 4;
val[d as usize] = V::Unknown;
scaled[d as usize] = keep;
recid[d as usize] = None;
}
}
// `add rD,[class+0x28]` turns `idx * 80` into the record's own address. From here every
// store through `rD` is a field of one binding.
Mnemonic::Add => {
if let Some(d) = gpr(insn.op0_register()) {
let base = scaled[d as usize] && insn.op1_kind() == OpKind::Memory;
val[d as usize] = V::Unknown;
scaled[d as usize] = false;
recid[d as usize] = base.then(|| {
next_rec += 1;
next_rec
});
}
}
// `movq xmm,[rip+slot]` loads a RELOCATED pointer — the script-facing name arrives this
// way rather than as a `lea`, and reading it needs the relocation applied, which
// `read_ptr` does. `movq xmm,r64` and the reverse also appear.
Mnemonic::Movq | Mnemonic::Movd => {
if let Some(x) = xmm(insn.op0_register()) {
let lo = if insn.op1_kind() == OpKind::Memory {
if insn.is_ip_rel_memory_operand() {
img.read_ptr(insn.ip_rel_memory_address())
.map_or(V::Unknown, V::Const)
} else {
V::Unknown
}
} else if let Some(s) = gpr(insn.op1_register()) {
val[s as usize]
} else {
V::Unknown
};
// `movq` zeroes the upper half; that matters because the high name is inserted
// afterwards and must not inherit a stale value.
xr[x as usize] = (lo, V::Const(0));
}
}
// `pinsrq xmm,r64,1` — the second name packed into the high half.
Mnemonic::Pinsrq => {
if let (Some(x), Some(s)) = (xmm(insn.op0_register()), gpr(insn.op1_register()))
&& insn.op2_kind() == OpKind::Immediate8
{
let v = val[s as usize];
if insn.immediate8() == 1 {
xr[x as usize].1 = v;
} else {
xr[x as usize].0 = v;
}
}
}
// `movddup xmm,[rip+slot]` — ONE pointer written into both halves. This is the form the
// compiler picks when the script-facing name and the C++ name are the SAME string, which
// is the common case: only the bindings that need a distinct C++ name (usually a
// `Script_`-prefixed wrapper) load two pointers. Missing this mnemonic costs roughly
// four fifths of the registry on Dota, so it is not an edge case.
Mnemonic::Movddup => {
if let Some(x) = xmm(insn.op0_register()) {
let v = if insn.is_ip_rel_memory_operand() {
img.read_ptr(insn.ip_rel_memory_address())
.map_or(V::Unknown, V::Const)
} else {
V::Unknown
};
xr[x as usize] = (v, v);
}
}
// `punpcklqdq x0,x1` — the same pack, reached the other way.
Mnemonic::Punpcklqdq => {
if let (Some(a), Some(b)) = (xmm(insn.op0_register()), xmm(insn.op1_register()))
{
xr[a as usize] = (xr[a as usize].0, xr[b as usize].0);
}
}
// The 16-byte store that lands both names.
Mnemonic::Movups | Mnemonic::Movaps | Mnemonic::Movdqu | Mnemonic::Movdqa => {
if insn.op0_kind() == OpKind::Memory
&& let Some(x) = xmm(insn.op1_register())
&& let Some(b) = gpr(insn.memory_base())
&& insn.memory_index() == Register::None
&& let Some(rec) = recid[b as usize]
{
let d = insn.memory_displacement64() as i64;
if let Some(v) = xr[x as usize].0.konst() {
stores.insert((rec, d), v);
}
if let Some(v) = xr[x as usize].1.konst() {
stores.insert((rec, d + 8), v);
}
}
}
Mnemonic::Mov => {
// Store to a record-relative slot: `mov [base+d], reg` or `mov [base+d], imm`.
if insn.op0_kind() == OpKind::Memory
&& insn.memory_index() == Register::None
&& let Some(b) = gpr(insn.memory_base())
&& let Some(rec) = recid[b as usize]
{
let d = insn.memory_displacement64() as i64;
let v = match insn.op1_kind() {
// A 16-bit store carries the return type. The tracker follows full
// registers, so `mov [rec+0x18], r11w` reads back through `r11`.
OpKind::Register => {
gpr(insn.op1_register()).and_then(|s| val[s as usize].konst())
}
OpKind::Immediate8 | OpKind::Immediate16 | OpKind::Immediate32 => {
Some(insn.immediate32to64() as u64)
}
OpKind::Immediate32to64 | OpKind::Immediate8to64 => {
Some(insn.immediate64())
}
_ => None,
};
if let Some(v) = v {
stores.insert((rec, d), v);
}
continue;
}
// Register-to-register and immediate loads feed the tracker.
if let Some(d) = gpr(insn.op0_register()) {
// A plain `mov rD,rS` carries the RECORD IDENTITY across, not just the value.
// This is the whole reason identity is tracked instead of a per-register flag:
// the compiler routinely computes the base in one register, copies it to a
// second, and then reuses the first — writing half the record through each.
// Without this the second half is attributed to no record and the binding is
// lost. `CBaseEntity::AddNewModifier` is the case that exposed it.
recid[d as usize] = match insn.op1_kind() {
OpKind::Register => {
gpr(insn.op1_register()).and_then(|s| recid[s as usize])
}
_ => None,
};
scaled[d as usize] = false;
val[d as usize] = match insn.op1_kind() {
OpKind::Register => {
gpr(insn.op1_register()).map_or(V::Unknown, |s| val[s as usize])
}
OpKind::Immediate8
| OpKind::Immediate16
| OpKind::Immediate32
| OpKind::Immediate32to64
| OpKind::Immediate8to64 => V::Const(insn.immediate64()),
_ => V::Unknown,
};
}
}
_ => {
// Any other write invalidates the destination, so a stale constant cannot be
// attributed to a record it never reached.
if let Some(d) = gpr(insn.op0_register()) {
val[d as usize] = V::Unknown;
scaled[d as usize] = false;
recid[d as usize] = None;
}
if let Some(x) = xmm(insn.op0_register()) {
xr[x as usize] = (V::Unknown, V::Unknown);
}
}
}
}
// Group the collected stores by the record they were written to, then keep the groups that
// present the full binding shape.
// BTreeMap, and it is not a style choice: the emit order below decides which row survives the
// `(name, cpp_name)` dedup at the end, so a HashMap made that a hash-order coin flip in a
// byte-reproducible artifact. Record ids are minted in ascending address order by the `Add` arm,
// so ordering by id is the natural reading order and changes nothing outside a tie.
let mut groups: BTreeMap<u32, HashMap<i64, u64>> = BTreeMap::new();
for ((rec, d), v) in stores {
groups.entry(rec).or_default().insert(d, v);
}
for g in groups.values() {
// The initialiser writes the name pair at the record's own `+0`, so a group without both is
// not a binding — a partial match on some other structure, or a record whose construction
// the tracker only saw half of.
let (Some(&n), Some(&c)) = (g.get(&F_NAME), g.get(&F_CPP)) else {
continue;
};
let (Some(name), Some(cpp_name)) = (ident(img, n), ident(img, c)) else {
continue;
};
let ret_raw = g.get(&F_RET).copied().unwrap_or(u64::MAX);
if ret_raw > u16::MAX as u64 {
continue;
}
let ret_raw = ret_raw as u16;
// The implementation is recorded when present but NOT required. It is written at the top of
// the initialiser's next loop iteration, so whether it lands in this record's group depends
// on which register the compiler happened to reuse — a binding whose fields are otherwise
// complete must not be dropped over a scheduling accident. (The shared marshalling thunk at
// `+0x38` is read past for the same reason and no longer recorded: nothing consumed it, and
// the record-layout table in this module's header is where that offset is documented.)
//
// An earlier revision did require both, on the reasoning that `libserver` holds another table
// of similar stride (the network field serialisers) whose records carry no code pointer. That
// was treating a symptom: the record-base anchor above rejects those structurally, because
// they are not built by `idx*5 << 4 + [class+0x28]`. Requiring the pair on top of that cost
// real bindings — `CBaseEntity::EmitSound` among them — for no additional safety.
out.push(VScriptFunc {
name,
cpp_name,
description: g.get(&F_DESC).and_then(|&v| text(img, v, MAX_DESC)),
ret_raw,
ret: ret_type_name(ret_raw),
imp: g.get(&F_IMPL).and_then(|&v| decode_pmf(img, v)),
});
}
}
out.sort_by(|a, b| (&a.name, &a.cpp_name).cmp(&(&b.name, &b.cpp_name)));
out.dedup_by(|a, b| a.name == b.name && a.cpp_name == b.cpp_name);
out
}

View file

@ -26,10 +26,7 @@ impl XrefIndex {
pub fn build(img: &CodeImage) -> Self {
// Reliable gameplay entries (vtable slots + fn-pointers via relocations, plus call targets),
// then add the eh_frame starts (the runtime tail). Union = coverage of the whole binary.
let mut entries = crate::locate::candidate_entries(img);
entries.extend(img.eh_frame_functions().into_iter().map(|(s, _)| s));
entries.sort_unstable();
entries.dedup();
let entries = crate::locate::function_entries(img);
// Disassemble each function's [start, next) range independently across threads — this is the
// single biggest decode in the tool and the ranges vary wildly in size, so the atomic work