ship one record per function: merge the release set, gen reads it, descriptions as doc comments, gates for what was only claimed; v3.0
This commit is contained in:
parent
71ce34edd2
commit
3410a79b6a
28 changed files with 30596 additions and 955 deletions
251
src/abi.rs
251
src/abi.rs
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
250
src/concmd.rs
250
src/concmd.rs
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
25
src/elf.rs
25
src/elf.rs
|
|
@ -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())
|
||||
|
|
|
|||
13
src/lib.rs
13
src/lib.rs
|
|
@ -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
|
||||
|
|
|
|||
97
src/live.rs
97
src/live.rs
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
55
src/main.rs
55
src/main.rs
|
|
@ -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,
|
||||
|
|
|
|||
1006
src/pipeline.rs
1006
src/pipeline.rs
File diff suppressed because it is too large
Load diff
1004
src/produce.rs
1004
src/produce.rs
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
103
src/pulse.rs
103
src/pulse.rs
|
|
@ -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;
|
||||
|
|
|
|||
18
src/rtti.rs
18
src/rtti.rs
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
558
src/vscript.rs
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue