initial commit
This commit is contained in:
commit
a2922b8bad
59 changed files with 2684583 additions and 0 deletions
731
src/abi.rs
Normal file
731
src/abi.rs
Normal file
|
|
@ -0,0 +1,731 @@
|
|||
//! Derive a function's observable **ABI shape** — its SysV-AMD64 argument footprint — straight from
|
||||
//! the machine code, so a C++ *prototype* change (an edit to the argument list) becomes an offline
|
||||
//! diff instead of a silently-stale loader hook.
|
||||
//!
|
||||
//! The byte-signature already handles the function's *body* drifting across recompiles: a changed
|
||||
//! prologue is re-derived and re-validated. What a byte-signature CANNOT see is the argument list
|
||||
//! changing while the body's opening bytes stay recognisable — the sig still resolves, the offset
|
||||
//! still points at real code, `validate-live` still passes, yet a loader that calls the function with
|
||||
//! the OLD prototype now passes the wrong registers. That failure is invisible to every existing gate.
|
||||
//!
|
||||
//! This module recovers the one thing that pins the prototype: which argument registers the function
|
||||
//! reads as inputs. On SysV-AMD64 the first six integer/pointer arguments arrive in RDI, RSI, RDX,
|
||||
//! RCX, R8, R9 and the first eight floating arguments in XMM0..XMM7, each assigned left-to-right. A
|
||||
//! register is an *input* exactly when it is live-in at the entry — read on some path before being
|
||||
//! written. We compute that with a bounded backward liveness over the 14 argument registers, then
|
||||
//! read off the contiguous integer- and float-argument counts. The result is recompilation-invariant
|
||||
//! (a rebuild doesn't change which arguments a function takes) and moves precisely when the prototype
|
||||
//! does — so comparing it across builds flags exactly the prototype changes the byte-sig misses.
|
||||
//!
|
||||
//! 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.
|
||||
|
||||
use crate::elf::CodeImage;
|
||||
use iced_x86::{
|
||||
Decoder, DecoderOptions, FlowControl, Instruction, InstructionInfoFactory, Mnemonic, OpAccess,
|
||||
OpKind, Register,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Argument-register slots, in ABI order: 0..6 = RDI,RSI,RDX,RCX,R8,R9; 6..14 = XMM0..XMM7. A `u16`
|
||||
/// bitmask over these 14 slots is a function's live-in argument set.
|
||||
const N_INT: usize = 6;
|
||||
const N_XMM: usize = 8;
|
||||
|
||||
/// A function's recovered ABI shape: how many integer/pointer and floating arguments it reads, plus
|
||||
/// whether it also loads arguments off the stack (a 7th+ integer / 9th+ float argument, or a large
|
||||
/// by-value struct). The `(int_args, float_args)` pair is the stable cross-build key — `stack_args`
|
||||
/// is a best-effort extra signal, reported but not used to key the diff.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
|
||||
pub struct AbiShape {
|
||||
pub int_args: u8,
|
||||
pub float_args: u8,
|
||||
pub stack_args: bool,
|
||||
/// The register class of the return value — a prototype dimension the argument footprint can't see.
|
||||
pub ret_class: RetClass,
|
||||
}
|
||||
|
||||
/// How a function returns its result, recovered from the return paths. Complements the argument
|
||||
/// footprint: a change here (int↔float↔by-value) is a prototype change the arg counts alone miss, and
|
||||
/// `ByValue` marks the RVO/sret functions that are UNSAFE to blind-call — the caller must pass an
|
||||
/// output-buffer pointer in RDI, so calling with the object there makes the function WRITE into it
|
||||
/// (the `CSwapTeams::GetDisplayString` sret trap). Best-effort, with an explicit
|
||||
/// `Unknown` when the return path doesn't decode — so it only ever adds a signal, never a false one.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Default, Debug)]
|
||||
pub enum RetClass {
|
||||
/// No decodable return path (a forwarding thunk / tail call / undecoded) — no signal.
|
||||
#[default]
|
||||
Unknown,
|
||||
/// No result register written before returning (best-effort void).
|
||||
Void,
|
||||
/// Scalar / pointer result in RAX (the common case).
|
||||
Int,
|
||||
/// Floating result in XMM0.
|
||||
Float,
|
||||
/// Large by-value aggregate (sret): the function writes the result through its incoming RDI output
|
||||
/// pointer and returns that pointer. UNSAFE to call with an object in RDI.
|
||||
ByValue,
|
||||
}
|
||||
|
||||
impl RetClass {
|
||||
pub fn describe(self) -> &'static str {
|
||||
match self {
|
||||
RetClass::Unknown => "ret=?",
|
||||
RetClass::Void => "ret=void",
|
||||
RetClass::Int => "ret=int",
|
||||
RetClass::Float => "ret=float",
|
||||
RetClass::ByValue => "ret=byval",
|
||||
}
|
||||
}
|
||||
|
||||
/// Inverse of [`Self::describe`] — parse the token back. `None` for an unrecognised string.
|
||||
pub fn from_describe(s: &str) -> Option<RetClass> {
|
||||
Some(match s {
|
||||
"ret=?" => RetClass::Unknown,
|
||||
"ret=void" => RetClass::Void,
|
||||
"ret=int" => RetClass::Int,
|
||||
"ret=float" => RetClass::Float,
|
||||
"ret=byval" => RetClass::ByValue,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Order + on-disk form are pinned to `describe()`: `AbiSig` stores a `RetClass`, is serialized into the
|
||||
// model, and `mode_abi` sorts `AbiSig` with a tie-break "to the larger by Ord". Both must serialize and
|
||||
// order by the describe() token EXACTLY, or the consensus bytes move — so serialization is the
|
||||
// describe() token and Ord is that token's lexical order, not the enum's declaration order.
|
||||
impl Ord for RetClass {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
self.describe().cmp(other.describe())
|
||||
}
|
||||
}
|
||||
impl PartialOrd for RetClass {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
impl serde::Serialize for RetClass {
|
||||
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
s.serialize_str(self.describe())
|
||||
}
|
||||
}
|
||||
impl<'de> serde::Deserialize<'de> for RetClass {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let s = String::deserialize(d)?;
|
||||
RetClass::from_describe(&s)
|
||||
.ok_or_else(|| serde::de::Error::custom(format!("bad ret class {s:?}")))
|
||||
}
|
||||
}
|
||||
|
||||
impl AbiShape {
|
||||
/// The recompilation-invariant identity used to compare shapes across builds. Deliberately omits
|
||||
/// `stack_args` (a frame-layout heuristic that a rebuild could flip) so a diff never false-flags
|
||||
/// on it. Return class is compared separately (`ret_class`) — a return-type change is reported
|
||||
/// distinctly from an argument-list change.
|
||||
pub fn key(&self) -> (u8, u8) {
|
||||
(self.int_args, self.float_args)
|
||||
}
|
||||
|
||||
/// Safe to blind-call with only a `this` pointer: at most one integer arg (the implicit `this`), no
|
||||
/// float args, no stack args, AND not an sret return. The live oracle invokes ONLY such methods —
|
||||
/// anything else needs arguments it doesn't have, so calling it would pass garbage. Both live callers
|
||||
/// gate on exactly this shape, so it lives here (one safety-critical definition) rather than inline.
|
||||
///
|
||||
/// The `ByValue` guard is the real teeth: an sret/RVO function receives a hidden output-buffer pointer in
|
||||
/// its FIRST integer register (RDI) and returns it, so a `this`-less sret (`int_args == 1` = just that
|
||||
/// pointer) would otherwise look "this-only" — and blind-calling it with the live object as RDI makes the
|
||||
/// callee WRITE its return value THROUGH the object = memory corruption. That is precisely what
|
||||
/// `RetClass::ByValue` marks, so consult it here rather than trust the naming convention alone.
|
||||
pub fn is_this_only(&self) -> bool {
|
||||
self.int_args <= 1
|
||||
&& self.float_args == 0
|
||||
&& !self.stack_args
|
||||
&& self.ret_class != RetClass::ByValue
|
||||
}
|
||||
}
|
||||
|
||||
/// The argument-register slot a register belongs to, or `None` if it isn't one. Sub-registers fold to
|
||||
/// their slot (EDI/DI/DIL -> RDI's slot); YMM/ZMM 0..7 fold to the matching XMM slot.
|
||||
fn arg_slot(r: Register) -> Option<usize> {
|
||||
match r.full_register() {
|
||||
Register::RDI => return Some(0),
|
||||
Register::RSI => return Some(1),
|
||||
Register::RDX => return Some(2),
|
||||
Register::RCX => return Some(3),
|
||||
Register::R8 => return Some(4),
|
||||
Register::R9 => return Some(5),
|
||||
_ => {}
|
||||
}
|
||||
// Vector argument registers: XMM/YMM/ZMM 0..7 all map to the same 8 float slots.
|
||||
let base = if r.is_xmm() {
|
||||
Register::XMM0
|
||||
} else if r.is_ymm() {
|
||||
Register::YMM0
|
||||
} else if r.is_zmm() {
|
||||
Register::ZMM0
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let i = (r as u32).wrapping_sub(base as u32) as usize;
|
||||
(i < N_XMM).then_some(N_INT + i)
|
||||
}
|
||||
|
||||
/// The destination register of an instruction that decodes as read-writing that register but whose
|
||||
/// RESULT does not depend on the register's prior value — so it is a def, not a genuine input read.
|
||||
/// Three cases:
|
||||
/// (1) same-register zeroing idioms (`xor r,r`, `pxor x,x`, `vxorps x,x,x`);
|
||||
/// (2) same-register all-ones idioms (`pcmpeqd x,x`);
|
||||
/// (3) legacy scalar-SSE writes (`cvtsi2sd`, `sqrtsd`, `movsd`-reg, …) whose low lanes are FULLY
|
||||
/// written and whose read-write access only models the preserved UPPER lanes — a decode artifact.
|
||||
/// Case (3) is essential: without it a float argument's count would move with body codegen (a bare
|
||||
/// `cvtsi2sd xmm0,rax` decodes as reading xmm0; a dependency-broken `xorps xmm0,xmm0; cvtsi2sd …` does
|
||||
/// not), which is exactly the recompilation drift the shape must be immune to. Genuine RMW arithmetic
|
||||
/// (`addss`/`mulss`/…) is NOT listed — those really do read their destination, so their read is kept.
|
||||
/// VEX forms take their merge lanes from an explicit source operand, so their destination is a pure
|
||||
/// Write and never decodes as a false read.
|
||||
fn false_read_dst(insn: &Instruction) -> Option<Register> {
|
||||
let self_idiom = matches!(
|
||||
insn.mnemonic(),
|
||||
Mnemonic::Xor
|
||||
| Mnemonic::Sub
|
||||
| Mnemonic::Sbb
|
||||
| Mnemonic::Pxor
|
||||
| Mnemonic::Xorps
|
||||
| Mnemonic::Xorpd
|
||||
| Mnemonic::Vpxor
|
||||
| Mnemonic::Vxorps
|
||||
| Mnemonic::Vxorpd
|
||||
| Mnemonic::Pcmpeqb
|
||||
| Mnemonic::Pcmpeqw
|
||||
| Mnemonic::Pcmpeqd
|
||||
| Mnemonic::Pcmpeqq
|
||||
);
|
||||
if self_idiom {
|
||||
match insn.op_count() {
|
||||
// legacy 2-operand: `xor r, r` / `pcmpeqd x, x`
|
||||
2 if insn.op0_kind() == OpKind::Register
|
||||
&& insn.op1_kind() == OpKind::Register
|
||||
&& insn.op0_register() == insn.op1_register() =>
|
||||
{
|
||||
return Some(insn.op0_register());
|
||||
}
|
||||
// VEX 3-operand: `vpxor dst, src, src`
|
||||
3 if insn.op1_kind() == OpKind::Register
|
||||
&& insn.op2_kind() == OpKind::Register
|
||||
&& insn.op1_register() == insn.op2_register() =>
|
||||
{
|
||||
return Some(insn.op0_register());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Legacy scalar-SSE merge-only writes: low lanes fully written, old value doesn't feed the result.
|
||||
let merge_only = matches!(
|
||||
insn.mnemonic(),
|
||||
Mnemonic::Movss
|
||||
| Mnemonic::Movsd
|
||||
| Mnemonic::Cvtsi2ss
|
||||
| Mnemonic::Cvtsi2sd
|
||||
| Mnemonic::Cvtss2sd
|
||||
| Mnemonic::Cvtsd2ss
|
||||
| Mnemonic::Sqrtss
|
||||
| Mnemonic::Sqrtsd
|
||||
| Mnemonic::Roundss
|
||||
| Mnemonic::Roundsd
|
||||
| Mnemonic::Rcpss
|
||||
| Mnemonic::Rsqrtss
|
||||
);
|
||||
(merge_only && insn.op0_kind() == OpKind::Register).then(|| insn.op0_register())
|
||||
}
|
||||
|
||||
/// One instruction's effect on the argument registers: `(use_mask, def_mask, reads_a_stack_arg)`.
|
||||
/// `use` = arg slots read (Read/CondRead/ReadWrite) minus false-read destinations; `def` = arg slots
|
||||
/// fully written (>=32-bit Write/ReadWrite, plus false-read destinations, which kill upward liveness).
|
||||
/// Shared by `decode_region` and the tests so a test can never mirror-drift from the real logic.
|
||||
fn insn_effect(factory: &mut InstructionInfoFactory, insn: &Instruction) -> (u16, u16, bool) {
|
||||
let (mut use_m, mut def_m) = (0u16, 0u16);
|
||||
let mut stack = false;
|
||||
let info = factory.info(insn);
|
||||
for ur in info.used_registers() {
|
||||
let Some(slot) = arg_slot(ur.register()) else {
|
||||
continue;
|
||||
};
|
||||
if matches!(
|
||||
ur.access(),
|
||||
OpAccess::Read | OpAccess::CondRead | OpAccess::ReadWrite | OpAccess::ReadCondWrite
|
||||
) {
|
||||
use_m |= 1 << slot;
|
||||
}
|
||||
// A def kills upward liveness only for a full-width write (>=32-bit writes clear the upper
|
||||
// bits; an 8/16-bit partial write leaves the register partly live, so it doesn't kill).
|
||||
if matches!(ur.access(), OpAccess::Write | OpAccess::ReadWrite) && ur.register().size() >= 4
|
||||
{
|
||||
def_m |= 1 << slot;
|
||||
}
|
||||
}
|
||||
// Stack argument: a read of `[rbp + disp]` above the saved frame (return addr at +8, first stack
|
||||
// arg at +16). Best-effort — frame-pointer-omitted stack args aren't caught.
|
||||
for um in info.used_memory() {
|
||||
if um.base() == Register::RBP
|
||||
&& matches!(
|
||||
um.access(),
|
||||
OpAccess::Read | OpAccess::CondRead | OpAccess::ReadWrite
|
||||
)
|
||||
&& (16..0x1000).contains(&(um.displacement() as i64))
|
||||
{
|
||||
stack = true;
|
||||
}
|
||||
}
|
||||
// A false-read destination (zeroing/all-ones idiom, or a scalar-SSE merge-only write) is a def,
|
||||
// not a use — even though it decodes as read-write of that register.
|
||||
if let Some(dst) = false_read_dst(insn)
|
||||
&& let Some(slot) = arg_slot(dst)
|
||||
{
|
||||
use_m &= !(1 << slot);
|
||||
def_m |= 1 << slot;
|
||||
}
|
||||
(use_m, def_m, stack)
|
||||
}
|
||||
|
||||
/// How an instruction writes RAX — the return-value register. `FromRdi` is the sret / return-this tell
|
||||
/// (`mov rax, rdi` / `lea rax, [rdi]`): RAX takes the incoming output pointer.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum RaxWrite {
|
||||
None,
|
||||
FromRdi,
|
||||
Other,
|
||||
}
|
||||
|
||||
/// One instruction's effect on the RETURN registers: does it write RAX (and from RDI?), does it write
|
||||
/// XMM0, and does it store through RDI (the sret output-write). Used to classify the return value.
|
||||
fn result_effect(
|
||||
factory: &mut InstructionInfoFactory,
|
||||
insn: &Instruction,
|
||||
) -> (RaxWrite, bool, bool) {
|
||||
// The sret / return-this tell: a full 64-bit RAX <- RDI copy.
|
||||
let rax_from_rdi = match insn.mnemonic() {
|
||||
Mnemonic::Mov => {
|
||||
insn.op0_kind() == OpKind::Register
|
||||
&& insn.op0_register() == Register::RAX
|
||||
&& insn.op1_kind() == OpKind::Register
|
||||
&& insn.op1_register() == Register::RDI
|
||||
}
|
||||
Mnemonic::Lea => {
|
||||
insn.op0_kind() == OpKind::Register
|
||||
&& insn.op0_register() == Register::RAX
|
||||
&& insn.memory_base() == Register::RDI
|
||||
&& insn.memory_index() == Register::None
|
||||
&& insn.memory_displacement64() == 0
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
let info = factory.info(insn);
|
||||
let (mut rax_written, mut xmm0_written) = (false, false);
|
||||
for ur in info.used_registers() {
|
||||
if !matches!(ur.access(), OpAccess::Write | OpAccess::ReadWrite) {
|
||||
continue;
|
||||
}
|
||||
// RAX written as a full (>=32-bit) value = a scalar/pointer return candidate.
|
||||
if ur.register().full_register() == Register::RAX && ur.register().size() >= 4 {
|
||||
rax_written = true;
|
||||
}
|
||||
// XMM0/YMM0/ZMM0 (the float-return slot) written = a float return candidate.
|
||||
if arg_slot(ur.register()) == Some(N_INT) {
|
||||
xmm0_written = true;
|
||||
}
|
||||
}
|
||||
// A store through RDI as base = the function uses RDI as an output buffer (the sret write).
|
||||
let stores_rdi = info.used_memory().iter().any(|um| {
|
||||
um.base() == Register::RDI && matches!(um.access(), OpAccess::Write | OpAccess::ReadWrite)
|
||||
});
|
||||
let rax = if rax_from_rdi {
|
||||
RaxWrite::FromRdi
|
||||
} else if rax_written {
|
||||
RaxWrite::Other
|
||||
} else {
|
||||
RaxWrite::None
|
||||
};
|
||||
(rax, xmm0_written, stores_rdi)
|
||||
}
|
||||
|
||||
/// Classify the return value from the decoded region: for each `ret`, find the nearest preceding result
|
||||
/// write and read off its register class, then take a consensus. `ByValue` (sret) requires BOTH the
|
||||
/// return-the-RDI-pointer pattern AND a store through RDI — so a plain `return this` (returns RDI but
|
||||
/// doesn't write through it) reads as an `Int` pointer return, not a by-value trap.
|
||||
fn derive_ret_class(list: &[Insn], stores_rdi: bool) -> RetClass {
|
||||
let mut votes: Vec<RetClass> = Vec::new();
|
||||
for (r, insn) in list.iter().enumerate() {
|
||||
if !insn.is_ret {
|
||||
continue;
|
||||
}
|
||||
let mut cls = RetClass::Void; // no result write found before the return
|
||||
for k in (r.saturating_sub(64)..r).rev() {
|
||||
match list[k].rax {
|
||||
RaxWrite::FromRdi => {
|
||||
cls = if stores_rdi {
|
||||
RetClass::ByValue
|
||||
} else {
|
||||
RetClass::Int
|
||||
};
|
||||
break;
|
||||
}
|
||||
RaxWrite::Other => {
|
||||
cls = RetClass::Int;
|
||||
break;
|
||||
}
|
||||
RaxWrite::None if list[k].xmm0 => {
|
||||
cls = RetClass::Float;
|
||||
break;
|
||||
}
|
||||
RaxWrite::None => {}
|
||||
}
|
||||
}
|
||||
votes.push(cls);
|
||||
}
|
||||
if votes.is_empty() {
|
||||
return RetClass::Unknown; // no return path (thunk / tail call)
|
||||
}
|
||||
if votes.contains(&RetClass::ByValue) {
|
||||
return RetClass::ByValue; // sret is definitive wherever it appears
|
||||
}
|
||||
match (
|
||||
votes.contains(&RetClass::Int),
|
||||
votes.contains(&RetClass::Float),
|
||||
) {
|
||||
(true, true) => RetClass::Unknown, // paths disagree on the return register — ambiguous
|
||||
(true, false) => RetClass::Int,
|
||||
(false, true) => RetClass::Float,
|
||||
(false, false) => RetClass::Void,
|
||||
}
|
||||
}
|
||||
|
||||
/// One decoded instruction's argument-register effect + return effect + in-function successors.
|
||||
struct Insn {
|
||||
ip: u64,
|
||||
use_m: u16, // arg slots read before this instruction can write them (a use)
|
||||
def_m: u16, // arg slots fully written (kills upward liveness)
|
||||
rax: RaxWrite, // how it writes RAX (the return register)
|
||||
xmm0: bool, // whether it writes XMM0 (the float-return register)
|
||||
is_ret: bool, // whether it returns
|
||||
succ: Vec<u64>, // successor instruction addresses inside the analysed region
|
||||
}
|
||||
|
||||
/// Decode the function at `entry` into its bounded reachable instructions, following the same
|
||||
/// conditional/unconditional control flow as the fingerprinter. Each instruction records which
|
||||
/// argument registers it uses/defs (for liveness) and its in-region successors (for the CFG). Also
|
||||
/// returns whether a stack argument was read anywhere in the region.
|
||||
fn decode_region(img: &CodeImage, entry: u64) -> Option<(Vec<Insn>, bool, bool)> {
|
||||
const MAX_SPAN: usize = 96 * 1024;
|
||||
const MAX_INSNS: usize = 8000;
|
||||
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 mut factory = InstructionInfoFactory::new();
|
||||
let mut recs: HashMap<u64, Insn> = HashMap::new();
|
||||
let mut stack_args = false;
|
||||
let mut stores_rdi = false;
|
||||
let mut work = vec![entry];
|
||||
let mut insn = Instruction::default();
|
||||
while let Some(start) = work.pop() {
|
||||
if recs.contains_key(&start) || !in_span(start) {
|
||||
continue;
|
||||
}
|
||||
let off = (start - entry) as usize;
|
||||
let mut dec = Decoder::with_ip(64, &code[off..], start, DecoderOptions::NONE);
|
||||
if !dec.can_decode() {
|
||||
continue;
|
||||
}
|
||||
dec.decode_out(&mut insn);
|
||||
if insn.is_invalid() || insn.len() == 0 || recs.len() >= MAX_INSNS {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (use_m, def_m, stack_hit) = insn_effect(&mut factory, &insn);
|
||||
stack_args |= stack_hit;
|
||||
let (rax, xmm0, rdi_store) = result_effect(&mut factory, &insn);
|
||||
stores_rdi |= rdi_store;
|
||||
let is_ret = insn.flow_control() == FlowControl::Return;
|
||||
|
||||
let next = start + insn.len() as u64;
|
||||
let mut succ = Vec::new();
|
||||
match insn.flow_control() {
|
||||
FlowControl::Return | FlowControl::IndirectBranch => {}
|
||||
FlowControl::UnconditionalBranch => {
|
||||
let t = insn.near_branch_target();
|
||||
if in_span(t) {
|
||||
succ.push(t); // in-function jump; else it's a tail call (no in-region successor)
|
||||
}
|
||||
}
|
||||
FlowControl::ConditionalBranch => {
|
||||
succ.push(next);
|
||||
let t = insn.near_branch_target();
|
||||
if in_span(t) {
|
||||
succ.push(t);
|
||||
}
|
||||
}
|
||||
_ => succ.push(next), // fall-through (incl. call/indirect-call: the call reads no arg regs)
|
||||
}
|
||||
for &s in &succ {
|
||||
if !recs.contains_key(&s) {
|
||||
work.push(s);
|
||||
}
|
||||
}
|
||||
recs.insert(
|
||||
start,
|
||||
Insn {
|
||||
ip: start,
|
||||
use_m,
|
||||
def_m,
|
||||
rax,
|
||||
xmm0,
|
||||
is_ret,
|
||||
succ,
|
||||
},
|
||||
);
|
||||
}
|
||||
if recs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut list: Vec<Insn> = recs.into_values().collect();
|
||||
list.sort_by_key(|i| i.ip);
|
||||
Some((list, stack_args, stores_rdi))
|
||||
}
|
||||
|
||||
/// Recover the ABI shape of the function at `entry`, or `None` if it doesn't decode. Runs a bounded
|
||||
/// backward liveness over the 14 argument registers to find the entry's live-in set, then reads off
|
||||
/// the contiguous integer- and float-argument counts (a later argument register being live-in implies
|
||||
/// the earlier ones are arguments too — the SysV assignment is left-to-right and gap-free).
|
||||
pub fn abi_shape(img: &CodeImage, entry: u64) -> Option<AbiShape> {
|
||||
let (list, stack_args, stores_rdi) = decode_region(img, entry)?;
|
||||
let n = list.len();
|
||||
let idx: HashMap<u64, usize> = list.iter().enumerate().map(|(i, r)| (r.ip, i)).collect();
|
||||
let succ: Vec<Vec<usize>> = list
|
||||
.iter()
|
||||
.map(|r| r.succ.iter().filter_map(|s| idx.get(s).copied()).collect())
|
||||
.collect();
|
||||
|
||||
// live_in[i] = use[i] | (live_out[i] & !def[i]); live_out[i] = union of successors' live_in.
|
||||
// Iterating in reverse index order converges in a couple of passes on a mostly-forward CFG; the
|
||||
// pass cap bounds the pathological (deeply nested loops) case. Deterministic regardless.
|
||||
let mut live_in = vec![0u16; n];
|
||||
for _ in 0..64 {
|
||||
let mut changed = false;
|
||||
for i in (0..n).rev() {
|
||||
let mut out = 0u16;
|
||||
for &s in &succ[i] {
|
||||
out |= live_in[s];
|
||||
}
|
||||
let v = list[i].use_m | (out & !list[i].def_m);
|
||||
if v != live_in[i] {
|
||||
live_in[i] = v;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let entry_live = idx.get(&entry).map(|&i| live_in[i]).unwrap_or(live_in[0]);
|
||||
let int_args = (0..N_INT)
|
||||
.rev()
|
||||
.find(|&s| entry_live & (1 << s) != 0)
|
||||
.map_or(0, |s| s + 1) as u8;
|
||||
let float_args = (0..N_XMM)
|
||||
.rev()
|
||||
.find(|&s| entry_live & (1 << (N_INT + s)) != 0)
|
||||
.map_or(0, |s| s + 1) as u8;
|
||||
// `stack_args` only makes sense once the register slots are full; a stray rbp read below that is a
|
||||
// spill, not an argument.
|
||||
let stack_args = stack_args && (int_args as usize == N_INT || float_args as usize == N_XMM);
|
||||
Some(AbiShape {
|
||||
int_args,
|
||||
float_args,
|
||||
stack_args,
|
||||
ret_class: derive_ret_class(&list, stores_rdi),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Decode a tiny hand-assembled straight-line function and recover its shape through the REAL
|
||||
// per-instruction helper (`insn_effect`) + the real liveness formula — so a test can't pass while
|
||||
// the production path is wrong. (A single-successor chain; the fixpoint isn't exercised here.)
|
||||
fn shape_of(bytes: &[u8]) -> AbiShape {
|
||||
let entry = 0x1000u64;
|
||||
let mut factory = InstructionInfoFactory::new();
|
||||
let mut recs: Vec<Insn> = Vec::new();
|
||||
let mut stores_rdi = false;
|
||||
let mut dec = Decoder::with_ip(64, bytes, entry, DecoderOptions::NONE);
|
||||
let mut insn = Instruction::default();
|
||||
while dec.can_decode() {
|
||||
dec.decode_out(&mut insn);
|
||||
if insn.is_invalid() || insn.len() == 0 {
|
||||
break;
|
||||
}
|
||||
let (use_m, def_m, _) = insn_effect(&mut factory, &insn);
|
||||
let (rax, xmm0, rdi_store) = result_effect(&mut factory, &insn);
|
||||
stores_rdi |= rdi_store;
|
||||
let ip = insn.ip();
|
||||
let stop = insn.flow_control() == FlowControl::Return;
|
||||
let next = ip + insn.len() as u64;
|
||||
recs.push(Insn {
|
||||
ip,
|
||||
use_m,
|
||||
def_m,
|
||||
rax,
|
||||
xmm0,
|
||||
is_ret: stop,
|
||||
succ: if stop { vec![] } else { vec![next] },
|
||||
});
|
||||
if stop {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// straight-line liveness (single-successor chain)
|
||||
let n = recs.len();
|
||||
let mut live = vec![0u16; n];
|
||||
for i in (0..n).rev() {
|
||||
let out = recs[i].succ.first().map_or(0, |_| live[i + 1]);
|
||||
live[i] = recs[i].use_m | (out & !recs[i].def_m);
|
||||
}
|
||||
let el = live[0];
|
||||
let int_args = (0..N_INT)
|
||||
.rev()
|
||||
.find(|&s| el & (1 << s) != 0)
|
||||
.map_or(0, |s| s + 1) as u8;
|
||||
let float_args = (0..N_XMM)
|
||||
.rev()
|
||||
.find(|&s| el & (1 << (N_INT + s)) != 0)
|
||||
.map_or(0, |s| s + 1) as u8;
|
||||
AbiShape {
|
||||
int_args,
|
||||
float_args,
|
||||
stack_args: false,
|
||||
ret_class: derive_ret_class(&recs, stores_rdi),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mov_rax_rdi_is_one_int_arg() {
|
||||
// mov rax, rdi ; ret
|
||||
assert_eq!(shape_of(&[0x48, 0x89, 0xF8, 0xC3]).key(), (1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deref_this_is_one_int_arg() {
|
||||
// mov rax, [rdi+8] ; ret (rdi read as a memory base = `this` pointer)
|
||||
assert_eq!(shape_of(&[0x48, 0x8B, 0x47, 0x08, 0xC3]).key(), (1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xor_eax_eax_is_zero_args() {
|
||||
// xor eax, eax ; ret (zeroing idiom is a def, not a use; eax isn't an arg reg anyway)
|
||||
assert_eq!(shape_of(&[0x31, 0xC0, 0xC3]).key(), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_rdx_fills_earlier_int_args() {
|
||||
// mov rax, rdx ; ret — rdx (slot 2) live-in => contiguity fills rdi, rsi => 3 int args
|
||||
assert_eq!(shape_of(&[0x48, 0x89, 0xD0, 0xC3]).key(), (3, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn addss_reads_two_float_args() {
|
||||
// addss xmm0, xmm1 ; ret — xmm0 (read-write) + xmm1 (read) live-in => 2 float args
|
||||
assert_eq!(shape_of(&[0xF3, 0x0F, 0x58, 0xC1, 0xC3]).key(), (0, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pxor_self_is_not_a_float_arg() {
|
||||
// pxor xmm0, xmm0 ; ret — zeroing idiom, xmm0 is a def not an input
|
||||
assert_eq!(shape_of(&[0x66, 0x0F, 0xEF, 0xC0, 0xC3]).key(), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pcmpeqd_self_all_ones_is_not_a_float_arg() {
|
||||
// pcmpeqd xmm2, xmm2 ; ret — all-ones idiom (result independent of xmm2's prior value)
|
||||
assert_eq!(shape_of(&[0x66, 0x0F, 0x76, 0xD2, 0xC3]).key(), (0, 0));
|
||||
}
|
||||
|
||||
// --- scalar-SSE merge-only writes must NOT be counted as float arguments (regression guards) ---
|
||||
|
||||
#[test]
|
||||
fn cvtsi2sd_dest_is_not_a_float_arg() {
|
||||
// cvtsi2sd xmm0, edi ; ret — converts an INT arg (edi) to double; xmm0 is a merge-write dest,
|
||||
// its read-write access is an upper-lane artifact, not a float argument => (1 int, 0 float).
|
||||
assert_eq!(shape_of(&[0xF2, 0x0F, 0x2A, 0xC7, 0xC3]).key(), (1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cvtsi2sd_shape_is_codegen_invariant() {
|
||||
// The SAME function must shape identically whether or not the compiler emits a
|
||||
// dependency-breaking `xorps xmm0,xmm0` before the convert.
|
||||
let bare = shape_of(&[0xF2, 0x0F, 0x2A, 0xC7, 0xC3]); // cvtsi2sd xmm0,edi ; ret
|
||||
let broken = shape_of(&[0x0F, 0x57, 0xC0, 0xF2, 0x0F, 0x2A, 0xC7, 0xC3]); // xorps xmm0,xmm0 ; …
|
||||
assert_eq!(bare.key(), (1, 0));
|
||||
assert_eq!(broken.key(), bare.key());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn movsd_reg_dest_does_not_inflate_float_args() {
|
||||
// movsd xmm2, xmm0 ; ret — copies float arg xmm0 into scratch xmm2; only xmm0 is an argument.
|
||||
assert_eq!(shape_of(&[0xF2, 0x0F, 0x10, 0xD0, 0xC3]).key(), (0, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqrtsd_scratch_dest_not_counted() {
|
||||
// sqrtsd xmm3, xmm1 ; ret — result into scratch xmm3 from float arg xmm1; xmm1 (slot 1) fills
|
||||
// xmm0 by contiguity => 2 float args, NOT 4 (xmm3 the merge-dest must not inflate the count).
|
||||
assert_eq!(shape_of(&[0xF2, 0x0F, 0x51, 0xD9, 0xC3]).key(), (0, 2));
|
||||
}
|
||||
|
||||
// --- return class ---
|
||||
|
||||
#[test]
|
||||
fn scalar_return_is_int() {
|
||||
// mov eax, edi ; ret — returns a scalar in RAX.
|
||||
assert_eq!(shape_of(&[0x89, 0xF8, 0xC3]).ret_class, RetClass::Int);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_return_is_float() {
|
||||
// movsd xmm0, xmm1 ; ret — the return register is XMM0.
|
||||
assert_eq!(
|
||||
shape_of(&[0xF2, 0x0F, 0x10, 0xC1, 0xC3]).ret_class,
|
||||
RetClass::Float
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn return_this_pointer_is_int_not_byval() {
|
||||
// mov rax, rdi ; ret — returns the RDI pointer but never writes THROUGH it, so it's a plain
|
||||
// pointer return (`return this`), not an sret trap.
|
||||
assert_eq!(shape_of(&[0x48, 0x89, 0xF8, 0xC3]).ret_class, RetClass::Int);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sret_write_through_rdi_is_byval() {
|
||||
// mov [rdi], rsi ; mov rax, rdi ; ret — writes the result through the incoming RDI output
|
||||
// pointer AND returns it => the by-value (sret) shape that is unsafe to blind-call.
|
||||
assert_eq!(
|
||||
shape_of(&[0x48, 0x89, 0x37, 0x48, 0x89, 0xF8, 0xC3]).ret_class,
|
||||
RetClass::ByValue
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_ret_is_void() {
|
||||
// ret — no result register written before returning.
|
||||
assert_eq!(shape_of(&[0xC3]).ret_class, RetClass::Void);
|
||||
}
|
||||
}
|
||||
717
src/elf.rs
Normal file
717
src/elf.rs
Normal file
|
|
@ -0,0 +1,717 @@
|
|||
//! Minimal hand-rolled ELF64 reader for CS2 Linux `.so` files.
|
||||
//!
|
||||
//! Two jobs: (1) expose executable code for signature scanning, and (2) expose the metadata
|
||||
//! the vtable/RTTI resolver needs — sections by name, dynamic symbols, and a relocation map
|
||||
//! (vtable slots in `.data.rel.ro` are 0 on disk and supplied by `.rela.dyn` at load, so we
|
||||
//! reconstruct their values here). No external ELF crate; the ELF64 layout is fixed.
|
||||
|
||||
use crate::sig::Pattern;
|
||||
use anyhow::{Context, Result, ensure};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Sec {
|
||||
typ: u32,
|
||||
flags: u64,
|
||||
addr: u64,
|
||||
off: usize,
|
||||
size: usize,
|
||||
link: usize,
|
||||
entsize: usize,
|
||||
}
|
||||
|
||||
pub struct CodeImage {
|
||||
data: Vec<u8>,
|
||||
exec: Vec<(usize, u64, usize)>, // (file_off, vaddr, size) of executable sections
|
||||
secs: Vec<Sec>,
|
||||
sym_addr: HashMap<String, u64>, // symbol name -> vaddr
|
||||
reloc: HashMap<u64, u64>, // vaddr slot -> resolved pointer value
|
||||
reloc_by_val: HashMap<u64, Vec<u64>>, // pointer value -> slot vaddrs holding it
|
||||
kind_at: HashMap<u64, KindTag>, // typeinfo vaddr -> its Itanium kind (by reloc symbol name)
|
||||
}
|
||||
|
||||
// These read attacker-controlled offsets, so they are bounds- AND overflow-safe: an out-of-range read
|
||||
// returns 0 (a truncated field is treated as zero, which downstream validity checks reject) rather
|
||||
// than panicking. This alone removes the largest class of malformed-input panics.
|
||||
fn u16le(b: &[u8], o: usize) -> u16 {
|
||||
o.checked_add(2)
|
||||
.and_then(|e| b.get(o..e))
|
||||
.and_then(|s| s.try_into().ok())
|
||||
.map_or(0, u16::from_le_bytes)
|
||||
}
|
||||
fn u32le(b: &[u8], o: usize) -> u32 {
|
||||
o.checked_add(4)
|
||||
.and_then(|e| b.get(o..e))
|
||||
.and_then(|s| s.try_into().ok())
|
||||
.map_or(0, u32::from_le_bytes)
|
||||
}
|
||||
fn u64le(b: &[u8], o: usize) -> u64 {
|
||||
o.checked_add(8)
|
||||
.and_then(|e| b.get(o..e))
|
||||
.and_then(|s| s.try_into().ok())
|
||||
.map_or(0, u64::from_le_bytes)
|
||||
}
|
||||
fn cstr(b: &[u8], o: usize) -> String {
|
||||
let Some(sub) = b.get(o..) else {
|
||||
return String::new();
|
||||
};
|
||||
let end = sub.iter().position(|&c| c == 0).unwrap_or(sub.len());
|
||||
String::from_utf8_lossy(&sub[..end]).into_owned()
|
||||
}
|
||||
|
||||
/// Byte width of a DWARF exception-handling pointer encoding (its low nibble is the value format).
|
||||
/// Returns 0 for LEB128 / unsupported formats, which callers treat as "give up, use the fallback".
|
||||
fn dw_ptr_size(enc: u8) -> usize {
|
||||
match enc & 0x0f {
|
||||
0x02 | 0x0a => 2, // udata2 / sdata2
|
||||
0x03 | 0x0b => 4, // udata4 / sdata4
|
||||
0x04 | 0x0c => 8, // udata8 / sdata8
|
||||
0x00 => 8, // absptr (LP64)
|
||||
_ => 0, // uleb128 / sleb128 / unknown
|
||||
}
|
||||
}
|
||||
|
||||
const SHF_WRITE: u64 = 0x1;
|
||||
const SHF_EXECINSTR: u64 = 0x4;
|
||||
const SHF_ALLOC: u64 = 0x2;
|
||||
const SHT_NOBITS: u32 = 8;
|
||||
const SHT_DYNSYM: u32 = 11;
|
||||
const SHT_SYMTAB: u32 = 2;
|
||||
const SHT_RELA: u32 = 4;
|
||||
const R_X86_64_64: u32 = 1;
|
||||
const R_X86_64_RELATIVE: u32 = 8;
|
||||
const R_X86_64_GLOB_DAT: u32 = 6;
|
||||
|
||||
/// The Itanium `type_info` "kind" a typeinfo's `+0` field references. Recovered by the referenced SYMBOL
|
||||
/// NAME rather than its pointer value, because when the C++ runtime is DYNAMICALLY linked (old Source-2
|
||||
/// builds `DT_NEEDED libstdc++`) the three kind vtables are UND imports with value 0 — so their `+0` reloc
|
||||
/// resolves offline to the same `0 + addend` for all three and can't be told apart (or found) by value.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum KindTag {
|
||||
Class, // __class_type_info — no bases
|
||||
Si, // __si_class_type_info — one public base at offset 0
|
||||
Vmi, // __vmi_class_type_info — multiple / virtual / non-public bases
|
||||
}
|
||||
|
||||
/// Map a `_ZTVN10__cxxabiv1…` kind-vtable symbol name to its [`KindTag`]. The dynstr stores the undecorated
|
||||
/// mangled name (symbol versioning lives in a separate table), so an exact match is correct.
|
||||
fn kind_tag_of(sym: &str) -> Option<KindTag> {
|
||||
match sym {
|
||||
"_ZTVN10__cxxabiv117__class_type_infoE" => Some(KindTag::Class),
|
||||
"_ZTVN10__cxxabiv120__si_class_type_infoE" => Some(KindTag::Si),
|
||||
"_ZTVN10__cxxabiv121__vmi_class_type_infoE" => Some(KindTag::Vmi),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
// Program-header type marking the `.eh_frame_hdr` FDE lookup table. The loader locates it this way,
|
||||
// so we do too — no dependence on section names, which stripping can remove.
|
||||
const PT_GNU_EH_FRAME: u32 = 0x6474_e550;
|
||||
|
||||
impl CodeImage {
|
||||
pub fn load(path: &Path) -> Result<Self> {
|
||||
let data = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;
|
||||
Self::from_bytes(data)
|
||||
}
|
||||
|
||||
/// Parse an in-memory ELF64 image — the filesystem-free core of `load`. This is the fuzz/property
|
||||
/// surface: it consumes fully attacker-controlled bytes (a Valve `.so`, or a fuzzer mutation) and
|
||||
/// MUST return `Err` on any malformed input, never panic (no out-of-bounds index, no overflow).
|
||||
pub fn from_bytes(data: Vec<u8>) -> Result<Self> {
|
||||
ensure!(
|
||||
data.len() > 64 && &data[0..4] == b"\x7fELF",
|
||||
"not an ELF file"
|
||||
);
|
||||
ensure!(data[4] == 2, "only ELF64 is supported");
|
||||
|
||||
let shoff = u64le(&data, 40) as usize;
|
||||
let shentsize = u16le(&data, 58) as usize;
|
||||
let shnum = u16le(&data, 60) as usize;
|
||||
ensure!(
|
||||
shentsize >= 64,
|
||||
"unexpected section header size {shentsize}"
|
||||
);
|
||||
// Section headers (matched by type/flags, never by name). Every field is attacker-controlled:
|
||||
// compute the header offset with checked arithmetic and keep only sections whose file range and
|
||||
// virtual range don't overflow / exceed the file. A malformed section becomes an inert empty
|
||||
// placeholder (so `link` indices stay aligned and every downstream slice/address stays in
|
||||
// bounds). A valid ELF skips none of this — its headers are all in range.
|
||||
let mut secs: Vec<Sec> = Vec::with_capacity(shnum);
|
||||
for i in 0..shnum {
|
||||
let hdr_ok = i
|
||||
.checked_mul(shentsize)
|
||||
.and_then(|x| shoff.checked_add(x))
|
||||
.filter(|&o| o.checked_add(64).is_some_and(|e| e <= data.len()));
|
||||
let Some(o) = hdr_ok else {
|
||||
secs.push(Sec::default());
|
||||
continue;
|
||||
};
|
||||
let typ = u32le(&data, o + 4);
|
||||
let off = u64le(&data, o + 24) as usize;
|
||||
let size = u64le(&data, o + 32) as usize;
|
||||
let addr = u64le(&data, o + 16);
|
||||
let file_ok =
|
||||
typ == SHT_NOBITS || off.checked_add(size).is_some_and(|e| e <= data.len());
|
||||
let addr_ok = addr.checked_add(size as u64).is_some();
|
||||
if file_ok && addr_ok {
|
||||
secs.push(Sec {
|
||||
typ,
|
||||
flags: u64le(&data, o + 8),
|
||||
addr,
|
||||
off,
|
||||
size,
|
||||
link: u32le(&data, o + 40) as usize,
|
||||
entsize: u64le(&data, o + 56) as usize,
|
||||
});
|
||||
} else {
|
||||
secs.push(Sec::default());
|
||||
}
|
||||
}
|
||||
|
||||
let exec: Vec<(usize, u64, usize)> = secs
|
||||
.iter()
|
||||
.filter(|s| s.flags & SHF_EXECINSTR != 0 && s.typ != SHT_NOBITS)
|
||||
.map(|s| (s.off, s.addr, s.size))
|
||||
.collect();
|
||||
ensure!(!exec.is_empty(), "no executable sections found");
|
||||
|
||||
// dynamic symbols (prefer .dynsym; fall back to .symtab if present)
|
||||
let mut sym_addr = HashMap::new();
|
||||
let mut sym_values: Vec<u64> = Vec::new();
|
||||
// Every symbol's name, pushed in lockstep with `sym_values` so a reloc's `r_sym` index recovers the
|
||||
// name even for UND (value-0) imports — the only way to identify the dynamically-linked kind vtables.
|
||||
let mut sym_names: Vec<String> = Vec::new();
|
||||
if let Some(symtab) = secs
|
||||
.iter()
|
||||
.find(|s| s.typ == SHT_DYNSYM)
|
||||
.or_else(|| secs.iter().find(|s| s.typ == SHT_SYMTAB))
|
||||
{
|
||||
let str_off = secs.get(symtab.link).map_or(0, |s| s.off);
|
||||
let n = if symtab.entsize >= 24 {
|
||||
symtab.size / symtab.entsize
|
||||
} else {
|
||||
0
|
||||
};
|
||||
for i in 0..n {
|
||||
let Some(o) = i
|
||||
.checked_mul(symtab.entsize)
|
||||
.and_then(|x| symtab.off.checked_add(x))
|
||||
.filter(|&o| o.checked_add(24).is_some_and(|e| e <= data.len()))
|
||||
else {
|
||||
break;
|
||||
};
|
||||
let name = cstr(&data, str_off.wrapping_add(u32le(&data, o) as usize));
|
||||
let value = u64le(&data, o + 8);
|
||||
sym_values.push(value);
|
||||
sym_names.push(name.clone()); // lockstep with sym_values, ALL symbols (incl. UND/value-0)
|
||||
if !name.is_empty() && value != 0 {
|
||||
sym_addr.entry(name.clone()).or_insert(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// relocations: reconstruct the as-loaded pointer values for .data.rel.ro etc.
|
||||
let mut reloc = HashMap::new();
|
||||
let mut reloc_by_val: HashMap<u64, Vec<u64>> = HashMap::new();
|
||||
let mut kind_at: HashMap<u64, KindTag> = HashMap::new();
|
||||
for s in secs.iter().filter(|s| s.typ == SHT_RELA) {
|
||||
let n = if s.entsize >= 24 {
|
||||
s.size / s.entsize
|
||||
} else {
|
||||
0
|
||||
};
|
||||
for i in 0..n {
|
||||
let Some(o) = i
|
||||
.checked_mul(s.entsize)
|
||||
.and_then(|x| s.off.checked_add(x))
|
||||
.filter(|&o| o.checked_add(24).is_some_and(|e| e <= data.len()))
|
||||
else {
|
||||
break;
|
||||
};
|
||||
let r_offset = u64le(&data, o);
|
||||
let r_info = u64le(&data, o + 8);
|
||||
let r_addend = u64le(&data, o + 16);
|
||||
let r_type = (r_info & 0xffff_ffff) as u32;
|
||||
let r_sym = (r_info >> 32) as usize;
|
||||
// A typeinfo's `+0` field is a symbolic reloc against a `__cxxabiv1` kind vtable. Record the
|
||||
// kind by the referenced symbol NAME (keyed by `r_offset` = the typeinfo's base vaddr), so a
|
||||
// dynamically-linked runtime — where the value resolves to a useless `0 + 0x10` for all three
|
||||
// kinds — is still classifiable. Recorded regardless of the value gate below.
|
||||
if matches!(r_type, R_X86_64_64 | R_X86_64_GLOB_DAT)
|
||||
&& let Some(tag) = sym_names.get(r_sym).and_then(|n| kind_tag_of(n))
|
||||
{
|
||||
kind_at.insert(r_offset, tag);
|
||||
}
|
||||
let val = match r_type {
|
||||
R_X86_64_RELATIVE => r_addend,
|
||||
R_X86_64_64 | R_X86_64_GLOB_DAT => sym_values
|
||||
.get(r_sym)
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
.wrapping_add(r_addend),
|
||||
_ => continue,
|
||||
};
|
||||
if val != 0 {
|
||||
reloc.insert(r_offset, val);
|
||||
reloc_by_val.entry(val).or_default().push(r_offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
data,
|
||||
exec,
|
||||
secs,
|
||||
sym_addr,
|
||||
reloc,
|
||||
reloc_by_val,
|
||||
kind_at,
|
||||
})
|
||||
}
|
||||
|
||||
/// Virtual addresses where `pat` matches inside any executable section.
|
||||
pub fn find(&self, pat: &Pattern) -> Vec<u64> {
|
||||
let mut hits = Vec::new();
|
||||
for &(off, vaddr, size) in &self.exec {
|
||||
let end = (off + size).min(self.data.len());
|
||||
if off >= end {
|
||||
continue;
|
||||
}
|
||||
for m in pat.find_all(&self.data[off..end]) {
|
||||
hits.push(vaddr + m as u64);
|
||||
}
|
||||
}
|
||||
hits
|
||||
}
|
||||
|
||||
/// Executable bytes starting at virtual address `vaddr` (to the end of its section).
|
||||
pub fn code_at(&self, vaddr: u64) -> Option<&[u8]> {
|
||||
for &(off, sec_va, size) in &self.exec {
|
||||
if vaddr >= sec_va && vaddr < sec_va + size as u64 {
|
||||
let start = off + (vaddr - sec_va) as usize;
|
||||
let end = (off + size).min(self.data.len());
|
||||
if start < end {
|
||||
return Some(&self.data[start..end]);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Executable bytes for the half-open virtual range `[start, end)` — used to disassemble a
|
||||
/// single function from its known `.eh_frame` boundary (so linear decode can't misalign on data
|
||||
/// between functions).
|
||||
pub fn code_range(&self, start: u64, end: u64) -> Option<&[u8]> {
|
||||
let all = self.code_at(start)?;
|
||||
let len = end.checked_sub(start)? as usize;
|
||||
Some(&all[..len.min(all.len())])
|
||||
}
|
||||
|
||||
/// Is `vaddr` inside an executable section (i.e. plausibly a function pointer)?
|
||||
pub fn is_code(&self, vaddr: u64) -> bool {
|
||||
self.exec
|
||||
.iter()
|
||||
.any(|&(_, va, size)| vaddr >= va && vaddr < va + size as u64)
|
||||
}
|
||||
|
||||
/// Executable sections as `(vaddr, bytes)` for linear disassembly.
|
||||
pub fn exec_blocks(&self) -> Vec<(u64, &[u8])> {
|
||||
self.exec
|
||||
.iter()
|
||||
.filter_map(|&(off, va, size)| {
|
||||
let end = (off + size).min(self.data.len());
|
||||
(off < end).then(|| (va, &self.data[off..end]))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Relocation values that point into executable code — vtable slots and function pointers, i.e.
|
||||
/// a large set of real function entry addresses obtained without disassembling anything.
|
||||
pub fn code_pointer_targets(&self) -> Vec<u64> {
|
||||
let mut out: Vec<u64> = self
|
||||
.reloc
|
||||
.values()
|
||||
.copied()
|
||||
.filter(|&v| self.is_code(v))
|
||||
.collect();
|
||||
out.sort_unstable();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
/// Raw allocated bytes at `vaddr`, up to `len`.
|
||||
fn data_at(&self, vaddr: u64, len: usize) -> Option<&[u8]> {
|
||||
for s in &self.secs {
|
||||
if s.flags & SHF_ALLOC != 0
|
||||
&& s.typ != SHT_NOBITS
|
||||
&& vaddr >= s.addr
|
||||
&& vaddr < s.addr + s.size as u64
|
||||
{
|
||||
let start = s.off + (vaddr - s.addr) as usize;
|
||||
if start + len <= self.data.len() {
|
||||
return Some(&self.data[start..start + len]);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Read-only initialised data bytes at `vaddr` (i.e. `.rodata`): allocated, not writable, not
|
||||
/// executable, not NOBITS. This is the build-invariant content — referenced strings, magic
|
||||
/// constants — so a fingerprint over it survives recompiles, unlike writable/relocated data.
|
||||
pub fn rodata_at(&self, vaddr: u64, len: usize) -> Option<&[u8]> {
|
||||
for s in &self.secs {
|
||||
if s.flags & SHF_ALLOC != 0
|
||||
&& s.flags & SHF_WRITE == 0
|
||||
&& s.flags & SHF_EXECINSTR == 0
|
||||
&& s.typ != SHT_NOBITS
|
||||
&& vaddr >= s.addr
|
||||
&& vaddr < s.addr + s.size as u64
|
||||
{
|
||||
let start = s.off + (vaddr - s.addr) as usize;
|
||||
let end = (start + len).min(s.off + s.size).min(self.data.len());
|
||||
if start < end {
|
||||
return Some(&self.data[start..end]);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The pointer value stored at `vaddr` — from the relocation map if relocated, else the
|
||||
/// raw qword in the file.
|
||||
pub fn read_ptr(&self, vaddr: u64) -> Option<u64> {
|
||||
if let Some(&v) = self.reloc.get(&vaddr) {
|
||||
return Some(v);
|
||||
}
|
||||
self.data_at(vaddr, 8).map(|b| u64le(b, 0))
|
||||
}
|
||||
|
||||
/// 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())
|
||||
}
|
||||
|
||||
/// The Itanium kind of the typeinfo at `ti`, recovered from its `+0` reloc's SYMBOL NAME. `Some` when
|
||||
/// the kind vtable is a named `__cxxabiv1` symbol (always so for a dynamically-linked runtime — the case
|
||||
/// the value-based check can't handle); `None` for a statically-linked build, where the caller falls
|
||||
/// back to comparing the resolved `+0` pointer against the in-image kind vtables.
|
||||
pub fn kind_at(&self, ti: u64) -> Option<KindTag> {
|
||||
self.kind_at.get(&ti).copied()
|
||||
}
|
||||
|
||||
/// Iterate `(slot_vaddr, resolved_pointer)` over every relocation — the reloc-driven way to
|
||||
/// sweep for vtables/typeinfos without brute-scanning section bytes.
|
||||
pub fn reloc_slots(&self) -> impl Iterator<Item = (u64, u64)> + '_ {
|
||||
self.reloc.iter().map(|(&k, &v)| (k, v))
|
||||
}
|
||||
|
||||
/// Raw signed qword at `vaddr` from file bytes — for non-relocated integers (e.g. an Itanium
|
||||
/// vtable's offset-to-top), where `read_ptr`'s reloc lookup would be meaningless.
|
||||
pub fn read_i64(&self, vaddr: u64) -> Option<i64> {
|
||||
self.data_at(vaddr, 8)
|
||||
.map(|b| i64::from_le_bytes(b[..8].try_into().unwrap()))
|
||||
}
|
||||
|
||||
/// Raw `u32` at `vaddr` from file bytes (e.g. an Itanium `__vmi` typeinfo's base count).
|
||||
pub fn read_u32(&self, vaddr: u64) -> Option<u32> {
|
||||
self.data_at(vaddr, 4).map(|b| u32le(b, 0))
|
||||
}
|
||||
|
||||
/// Raw `i32` at `vaddr` from file bytes (e.g. a schema field's inheritance offset).
|
||||
pub fn read_i32(&self, vaddr: u64) -> Option<i32> {
|
||||
self.data_at(vaddr, 4)
|
||||
.map(|b| i32::from_le_bytes(b[..4].try_into().unwrap()))
|
||||
}
|
||||
|
||||
/// Raw `u16` at `vaddr` from file bytes (e.g. a schema class's field count).
|
||||
pub fn read_u16(&self, vaddr: u64) -> Option<u16> {
|
||||
self.data_at(vaddr, 2).map(|b| u16le(b, 0))
|
||||
}
|
||||
|
||||
/// Raw `u8` at `vaddr` from file bytes (e.g. a schema class's base count).
|
||||
pub fn read_u8(&self, vaddr: u64) -> Option<u8> {
|
||||
self.data_at(vaddr, 1).map(|b| b[0])
|
||||
}
|
||||
|
||||
/// Is `vaddr` inside any allocated section (code or data)? The "does this pointer land in the
|
||||
/// image" test RTTI validation needs.
|
||||
pub fn contains(&self, vaddr: u64) -> bool {
|
||||
self.secs
|
||||
.iter()
|
||||
.any(|s| s.flags & SHF_ALLOC != 0 && vaddr >= s.addr && vaddr < s.addr + s.size as u64)
|
||||
}
|
||||
|
||||
pub fn symbol_addr(&self, name: &str) -> Option<u64> {
|
||||
self.sym_addr.get(name).copied()
|
||||
}
|
||||
|
||||
/// Virtual addresses of an exact byte string within allocated, non-executable sections
|
||||
/// (used to find RTTI name strings in `.rodata`).
|
||||
pub fn find_bytes(&self, needle: &[u8]) -> Vec<u64> {
|
||||
let mut out = Vec::new();
|
||||
if needle.is_empty() {
|
||||
return out;
|
||||
}
|
||||
for s in &self.secs {
|
||||
if s.flags & SHF_ALLOC == 0
|
||||
|| s.typ == SHT_NOBITS
|
||||
|| s.flags & SHF_EXECINSTR != 0
|
||||
|| s.off + s.size > self.data.len()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let hay = &self.data[s.off..s.off + s.size];
|
||||
let mut i = 0;
|
||||
while let Some(p) = memchr::memmem::find(&hay[i..], needle) {
|
||||
out.push(s.addr + (i + p) as u64);
|
||||
i += p + 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The NUL-terminated string at `vaddr` in any allocated, initialised section — RTTI `_ZTS`
|
||||
/// names, schema class/field names, referenced literals. Capped so a missing terminator (e.g.
|
||||
/// a bogus pointer into a non-string section) can't run to the end of the file.
|
||||
pub fn read_c_string(&self, vaddr: u64) -> Option<String> {
|
||||
for s in &self.secs {
|
||||
if s.flags & SHF_ALLOC != 0
|
||||
&& s.typ != SHT_NOBITS
|
||||
&& vaddr >= s.addr
|
||||
&& vaddr < s.addr + s.size as u64
|
||||
{
|
||||
let start = s.off + (vaddr - s.addr) as usize;
|
||||
let end = (s.off + s.size).min(self.data.len()).min(start + 4096);
|
||||
if start >= end {
|
||||
return None;
|
||||
}
|
||||
let rel = self.data[start..end].iter().position(|&c| c == 0)?;
|
||||
return Some(String::from_utf8_lossy(&self.data[start..start + rel]).into_owned());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Virtual address + file offset of `.eh_frame_hdr`, via the PT_GNU_EH_FRAME program header.
|
||||
fn eh_frame_hdr(&self) -> Option<(u64, usize)> {
|
||||
let d = &self.data;
|
||||
let phoff = u64le(d, 32) as usize;
|
||||
let phentsize = u16le(d, 54) as usize;
|
||||
let phnum = u16le(d, 56) as usize;
|
||||
if phentsize < 56 {
|
||||
return None;
|
||||
}
|
||||
for i in 0..phnum {
|
||||
let Some(o) = i
|
||||
.checked_mul(phentsize)
|
||||
.and_then(|x| phoff.checked_add(x))
|
||||
.filter(|&o| o.checked_add(56).is_some_and(|e| e <= d.len()))
|
||||
else {
|
||||
break;
|
||||
};
|
||||
if u32le(d, o) == PT_GNU_EH_FRAME {
|
||||
return Some((u64le(d, o + 16), u64le(d, o + 8) as usize));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Decode a DWARF-encoded value at `field_va`, applying its pcrel/datarel base. Returns
|
||||
/// `(value, byte_width)`. `datarel_base` is the `.eh_frame_hdr` vaddr (only used by datarel enc).
|
||||
fn read_enc(&self, enc: u8, field_va: u64, datarel_base: u64) -> Option<(u64, usize)> {
|
||||
let sz = dw_ptr_size(enc);
|
||||
if sz == 0 {
|
||||
return None;
|
||||
}
|
||||
let b = self.data_at(field_va, sz)?;
|
||||
let raw = match sz {
|
||||
2 => u16le(b, 0) as u64,
|
||||
4 => u32le(b, 0) as u64,
|
||||
_ => u64le(b, 0),
|
||||
};
|
||||
let base = match enc & 0x70 {
|
||||
0x00 => 0, // absolute — no base (also how lengths/sizes are stored)
|
||||
0x10 => field_va, // pcrel: relative to this field's own address
|
||||
0x30 => datarel_base, // datarel: relative to `.eh_frame_hdr`
|
||||
_ => return None,
|
||||
};
|
||||
let val = if matches!(enc & 0x0f, 0x0a..=0x0c) {
|
||||
let s = match sz {
|
||||
2 => raw as u16 as i16 as i64,
|
||||
4 => raw as u32 as i32 as i64,
|
||||
_ => raw as i64,
|
||||
};
|
||||
base.wrapping_add(s as u64)
|
||||
} else {
|
||||
base.wrapping_add(raw)
|
||||
};
|
||||
Some((val, sz))
|
||||
}
|
||||
|
||||
/// Byte length of the LEB128 value at `va` (the value itself is unused here — we only skip it).
|
||||
fn leb_len(&self, va: u64) -> Option<u64> {
|
||||
let mut n = 0u64;
|
||||
loop {
|
||||
let byte = self.data_at(va.wrapping_add(n), 1)?[0];
|
||||
n += 1;
|
||||
if byte & 0x80 == 0 {
|
||||
return Some(n);
|
||||
}
|
||||
if n >= 16 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The FDE pointer encoding a CIE advertises — its `'R'` augmentation byte. Absptr (0) when the
|
||||
/// CIE carries no `z`/`R` augmentation (then FDE addresses are absolute).
|
||||
fn cie_fde_enc(&self, cie_va: u64) -> u8 {
|
||||
let Some(head) = self.data_at(cie_va, 9) else {
|
||||
return 0;
|
||||
};
|
||||
if u32le(head, 4) != 0 {
|
||||
return 0; // CIE id field must be 0
|
||||
}
|
||||
let version = head[8];
|
||||
let mut p = cie_va.wrapping_add(9);
|
||||
let Some(aug) = self.read_c_string(p) else {
|
||||
return 0;
|
||||
};
|
||||
p = p.wrapping_add(aug.len() as u64 + 1);
|
||||
if !aug.starts_with('z') {
|
||||
return 0;
|
||||
}
|
||||
// code_align (uleb), data_align (sleb), return-addr reg (uleb v>=3 else 1 byte), aug_len (uleb)
|
||||
for _ in 0..2 {
|
||||
match self.leb_len(p) {
|
||||
Some(n) => p = p.wrapping_add(n),
|
||||
None => return 0,
|
||||
}
|
||||
}
|
||||
if version >= 3 {
|
||||
match self.leb_len(p) {
|
||||
Some(n) => p = p.wrapping_add(n),
|
||||
None => return 0,
|
||||
}
|
||||
} else {
|
||||
p = p.wrapping_add(1);
|
||||
}
|
||||
match self.leb_len(p) {
|
||||
Some(n) => p = p.wrapping_add(n),
|
||||
None => return 0,
|
||||
}
|
||||
// The augmentation letters after 'z' name the aug-data fields, in order.
|
||||
for c in aug.bytes().skip(1) {
|
||||
match c {
|
||||
b'R' => return self.data_at(p, 1).map_or(0, |b| b[0]),
|
||||
b'L' => p = p.wrapping_add(1),
|
||||
b'P' => {
|
||||
let Some(e) = self.data_at(p, 1).map(|b| b[0]) else {
|
||||
return 0;
|
||||
};
|
||||
p = p.wrapping_add(1 + dw_ptr_size(e) as u64);
|
||||
}
|
||||
b'S' | b'B' | b'G' => {}
|
||||
_ => return 0,
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
/// Parse the FDE at `fde_va` to `(pc_begin, pc_end)`, using its owning CIE's pointer encoding.
|
||||
/// `enc_cache` memoises CIE encodings (nearly all FDEs share one CIE).
|
||||
fn fde_range(&self, fde_va: u64, enc_cache: &mut HashMap<u64, u8>) -> Option<(u64, u64)> {
|
||||
let head = self.data_at(fde_va, 8)?;
|
||||
let len = u32le(head, 0);
|
||||
if len == 0 || len == 0xffff_ffff {
|
||||
return None; // terminator, or 64-bit DWARF (not emitted by the CS2 toolchain)
|
||||
}
|
||||
let cie_ptr = u32le(head, 4);
|
||||
if cie_ptr == 0 {
|
||||
return None; // a CIE, not an FDE
|
||||
}
|
||||
let cie_va = fde_va.wrapping_add(4).wrapping_sub(cie_ptr as u64);
|
||||
let enc = *enc_cache
|
||||
.entry(cie_va)
|
||||
.or_insert_with(|| self.cie_fde_enc(cie_va));
|
||||
let (pc_begin, sz) = self.read_enc(enc, fde_va.wrapping_add(8), 0)?;
|
||||
// PC_range follows PC_begin at the same width; it's an absolute size (no base applied).
|
||||
let rb = self.data_at(fde_va.wrapping_add(8).wrapping_add(sz as u64), sz)?;
|
||||
let range = match sz {
|
||||
2 => u16le(rb, 0) as u64,
|
||||
4 => u32le(rb, 0) as u64,
|
||||
_ => u64le(rb, 0),
|
||||
};
|
||||
Some((pc_begin, pc_begin.wrapping_add(range)))
|
||||
}
|
||||
|
||||
/// Every function `.eh_frame` unwind data describes, as sorted `(start, end)` virtual-address
|
||||
/// pairs. This enumerates far more functions than the dynamic symbol table exposes (stripped
|
||||
/// internal functions still need unwind info), making it the completeness denominator for
|
||||
/// coverage audits and the source of exact byte extents for the content-locator.
|
||||
pub fn eh_frame_functions(&self) -> Vec<(u64, u64)> {
|
||||
let Some((hdr_va, hdr_off)) = self.eh_frame_hdr() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let d = &self.data;
|
||||
// header: version(1) + eh_frame_ptr_enc(1) + fde_count_enc(1) + table_enc(1)
|
||||
let Some(hb) = hdr_off.checked_add(4).and_then(|e| d.get(hdr_off..e)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
if hb[0] != 1 {
|
||||
return Vec::new();
|
||||
}
|
||||
let ptr_sz = dw_ptr_size(hb[1]); // eh_frame_ptr encoding (we skip the pointer)
|
||||
let count_sz = dw_ptr_size(hb[2]);
|
||||
let table_enc = hb[3];
|
||||
let esz = dw_ptr_size(table_enc);
|
||||
if ptr_sz == 0 || count_sz == 0 || esz == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(table_off) = hdr_off
|
||||
.checked_add(4)
|
||||
.and_then(|x| x.checked_add(ptr_sz))
|
||||
.and_then(|count_off| count_off.checked_add(count_sz).map(|t| (count_off, t)))
|
||||
.filter(|&(_, t)| t <= d.len())
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let (count_off, table_off) = table_off;
|
||||
// fde_count is attacker-controlled; each table entry is `2*esz` bytes, so a real count can't
|
||||
// exceed the file. Cap it BEFORE any allocation — a crafted count would otherwise drive an
|
||||
// out-of-memory abort.
|
||||
let fde_count = match count_sz {
|
||||
2 => u16le(d, count_off) as usize,
|
||||
4 => u32le(d, count_off) as usize,
|
||||
_ => u64le(d, count_off) as usize,
|
||||
}
|
||||
.min(d.len() / (2 * esz).max(1) + 1);
|
||||
let entry_bytes = 2 * esz;
|
||||
let table_va = hdr_va.wrapping_add(table_off.wrapping_sub(hdr_off) as u64);
|
||||
let mut starts: Vec<(u64, u64)> = Vec::new(); // (fn start, fde vaddr)
|
||||
for i in 0..fde_count {
|
||||
let field_va = table_va.wrapping_add(i.wrapping_mul(entry_bytes) as u64);
|
||||
let (Some((start, _)), Some((fde_va, _))) = (
|
||||
self.read_enc(table_enc, field_va, hdr_va),
|
||||
self.read_enc(table_enc, field_va.wrapping_add(esz as u64), hdr_va),
|
||||
) else {
|
||||
break;
|
||||
};
|
||||
starts.push((start, fde_va));
|
||||
}
|
||||
starts.sort_unstable_by_key(|&(s, _)| s);
|
||||
let mut cache = HashMap::new();
|
||||
let mut out = Vec::with_capacity(starts.len());
|
||||
for (i, &(start, fde_va)) in starts.iter().enumerate() {
|
||||
// Prefer the FDE's own extent; fall back to the next function's start (padding included).
|
||||
let end = self
|
||||
.fde_range(fde_va, &mut cache)
|
||||
.map(|(_, e)| e)
|
||||
.filter(|&e| e > start)
|
||||
.unwrap_or_else(|| starts.get(i + 1).map_or(start, |&(s, _)| s));
|
||||
out.push((start, end));
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
163
src/emit.rs
Normal file
163
src/emit.rs
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
//! Generate a fresh, unique signature at a known address — the tool's product output.
|
||||
//!
|
||||
//! Walk instructions from the function entry, emit their bytes, but wildcard the
|
||||
//! position-dependent ones (RIP-relative displacements and near-branch targets) so the
|
||||
//! signature survives relocation. Stop as soon as the accumulated pattern matches exactly
|
||||
//! once in the binary. Pure integer/byte work; the only float-free dependency is the decoder.
|
||||
|
||||
use crate::elf::CodeImage;
|
||||
use crate::sig::Pattern;
|
||||
use iced_x86::{ConstantOffsets, Decoder, DecoderOptions, Instruction, OpKind};
|
||||
|
||||
/// Call `emit(k, masked)` for each byte `k` of a just-decoded instruction, where `masked` is true for
|
||||
/// the position-dependent bytes — a RIP-relative displacement or a near-branch target — that move on
|
||||
/// relocation and so must be wildcarded (in a signature) or normalized away (in a cross-build digest).
|
||||
/// The single source of truth for that masking, shared by `make_sig` and `normalized_digest` so the
|
||||
/// signature the tool ships and the digest it compares builds with can never drift apart.
|
||||
fn for_each_byte(
|
||||
instr: &Instruction,
|
||||
co: &ConstantOffsets,
|
||||
ilen: usize,
|
||||
mut emit: impl FnMut(usize, bool),
|
||||
) {
|
||||
let rip_rel = instr.is_ip_rel_memory_operand();
|
||||
let branch = (0..instr.op_count()).any(|i| {
|
||||
matches!(
|
||||
instr.op_kind(i),
|
||||
OpKind::NearBranch16 | OpKind::NearBranch32 | OpKind::NearBranch64
|
||||
)
|
||||
});
|
||||
for k in 0..ilen {
|
||||
let in_disp = rip_rel
|
||||
&& co.has_displacement()
|
||||
&& k >= co.displacement_offset()
|
||||
&& k < co.displacement_offset() + co.displacement_size();
|
||||
let in_imm = branch
|
||||
&& co.has_immediate()
|
||||
&& k >= co.immediate_offset()
|
||||
&& k < co.immediate_offset() + co.immediate_size();
|
||||
emit(k, in_disp || in_imm);
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a unique signature for the function at `vaddr`, or `None` if it can't be made unique
|
||||
/// within `max_bytes`.
|
||||
pub fn make_sig(img: &CodeImage, vaddr: u64, max_bytes: usize) -> Option<String> {
|
||||
let code = img.code_at(vaddr)?;
|
||||
let mut decoder = Decoder::with_ip(64, code, vaddr, DecoderOptions::NONE);
|
||||
let mut instr = Instruction::default();
|
||||
let mut tokens: Vec<String> = Vec::new();
|
||||
let mut off = 0usize;
|
||||
|
||||
while decoder.can_decode() && off < max_bytes {
|
||||
decoder.decode_out(&mut instr);
|
||||
let ilen = instr.len();
|
||||
if ilen == 0 || off + ilen > code.len() {
|
||||
break;
|
||||
}
|
||||
let co = decoder.get_constant_offsets(&instr);
|
||||
for_each_byte(&instr, &co, ilen, |k, masked| {
|
||||
tokens.push(if masked {
|
||||
"?".into()
|
||||
} else {
|
||||
format!("{:02X}", code[off + k])
|
||||
});
|
||||
});
|
||||
off += ilen;
|
||||
|
||||
if let Ok(pat) = Pattern::parse(&tokens.join(" "))
|
||||
&& img.find(&pat).len() == 1
|
||||
{
|
||||
return Some(tokens.join(" "));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// FNV-1a 64-bit digest of the function `[start, end)`, with the position-dependent bytes masked to a
|
||||
/// constant (the same masking `make_sig` wildcards). The digest is therefore recompilation-shift-
|
||||
/// invariant: a function whose body is unchanged but whose call/jump targets moved with the surrounding
|
||||
/// layout digests IDENTICALLY across builds, while a genuine opcode/operand edit changes it. This is the
|
||||
/// per-function identity `classify-change` compares two builds by. `None` if nothing decodes.
|
||||
pub fn normalized_digest(img: &CodeImage, start: u64, end: u64) -> Option<u64> {
|
||||
let code = img.code_at(start)?;
|
||||
let span = (end.saturating_sub(start) as usize).min(code.len());
|
||||
digest_code(&code[..span], start)
|
||||
}
|
||||
|
||||
/// The shared core of `normalized_digest`: FNV-1a over a raw code slice with the position-dependent
|
||||
/// bytes masked. Split out from the `CodeImage` wrapper so it can be unit-tested on hand-assembled
|
||||
/// bytes. `None` if nothing decodes.
|
||||
fn digest_code(code: &[u8], ip: u64) -> Option<u64> {
|
||||
if code.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut decoder = Decoder::with_ip(64, code, ip, DecoderOptions::NONE);
|
||||
let mut instr = Instruction::default();
|
||||
let mut h: u64 = 0xcbf29ce484222325; // FNV-1a offset basis
|
||||
let mut off = 0usize;
|
||||
let mut decoded = false;
|
||||
while decoder.can_decode() && off < code.len() {
|
||||
decoder.decode_out(&mut instr);
|
||||
let ilen = instr.len();
|
||||
if ilen == 0 || off + ilen > code.len() {
|
||||
break;
|
||||
}
|
||||
let co = decoder.get_constant_offsets(&instr);
|
||||
for_each_byte(&instr, &co, ilen, |k, masked| {
|
||||
// A masked byte hashes as a fixed 0 regardless of its build-specific value; kept in place
|
||||
// (not skipped) so instruction length still participates in the digest.
|
||||
let byte = if masked { 0 } else { code[off + k] };
|
||||
h = (h ^ byte as u64).wrapping_mul(0x100000001b3);
|
||||
});
|
||||
off += ilen;
|
||||
decoded = true;
|
||||
}
|
||||
decoded.then_some(h)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::digest_code;
|
||||
|
||||
#[test]
|
||||
fn masks_call_target_shift_invariant() {
|
||||
// Two builds of the same function whose only difference is where its `call rel32` lands (the
|
||||
// callee moved with the layout) must digest IDENTICALLY — the shift-invariance the whole
|
||||
// command rests on. `E8 xx xx xx xx` = call; the 4 immediate bytes differ, nothing else.
|
||||
let a = digest_code(&[0xE8, 0x11, 0x22, 0x33, 0x44, 0xC3], 0x1000); // call +0x44332211 ; ret
|
||||
let b = digest_code(&[0xE8, 0x55, 0x66, 0x77, 0x00, 0xC3], 0x1000); // call +0x00776655 ; ret
|
||||
assert_eq!(a, b);
|
||||
assert!(a.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_rip_relative_displacement() {
|
||||
// `lea rax, [rip+disp]` — the displacement moves every build; masking it makes the two equal.
|
||||
let a = digest_code(&[0x48, 0x8D, 0x05, 0x11, 0x22, 0x33, 0x44, 0xC3], 0x1000);
|
||||
let b = digest_code(&[0x48, 0x8D, 0x05, 0xAA, 0xBB, 0xCC, 0x00, 0xC3], 0x1000);
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinguishes_opcode_change() {
|
||||
// A real body edit (add vs sub) must change the digest — the masking must NOT wash it out.
|
||||
let add = digest_code(&[0x48, 0x01, 0xD8, 0xC3], 0x1000); // add rax, rbx ; ret
|
||||
let sub = digest_code(&[0x48, 0x29, 0xD8, 0xC3], 0x1000); // sub rax, rbx ; ret
|
||||
assert_ne!(add, sub);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinguishes_non_branch_immediate() {
|
||||
// A plain immediate (`mov eax, IMM`) is NOT masked — it's part of the function's identity, so a
|
||||
// changed constant is a real change, unlike a relocated branch/RIP displacement.
|
||||
let a = digest_code(&[0xB8, 0x01, 0x00, 0x00, 0x00, 0xC3], 0x1000); // mov eax, 1 ; ret
|
||||
let b = digest_code(&[0xB8, 0x02, 0x00, 0x00, 0x00, 0xC3], 0x1000); // mov eax, 2 ; ret
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_is_none() {
|
||||
assert_eq!(digest_code(&[], 0x1000), None);
|
||||
}
|
||||
}
|
||||
288
src/fingerprint.rs
Normal file
288
src/fingerprint.rs
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
//! Structural, recompilation-invariant fingerprint of a function — integer features only.
|
||||
//!
|
||||
//! Walks the control-flow graph from the entry point (bounded), decoding with iced-x86, and
|
||||
//! summarises shape into counts that survive a recompile: instruction/block/call/branch counts,
|
||||
//! distinct callees, RIP-relative data references, and a mnemonic-category histogram. No
|
||||
//! addresses or immediates enter the vector (those change every build); only structure does.
|
||||
|
||||
use crate::elf::CodeImage;
|
||||
use iced_x86::{Decoder, DecoderOptions, FlowControl, Instruction, Mnemonic, OpKind};
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub const NCATS: usize = 18;
|
||||
/// Hash buckets for the referenced-string content sketch.
|
||||
pub const NREF: usize = 32;
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub struct Fingerprint {
|
||||
pub size_bytes: u32,
|
||||
pub insns: u32,
|
||||
pub blocks: u32,
|
||||
pub calls: u32,
|
||||
pub distinct_callees: u32,
|
||||
pub cond_branches: u32,
|
||||
pub uncond_branches: u32,
|
||||
pub rets: u32,
|
||||
pub data_refs: u32,
|
||||
pub indirect: u32,
|
||||
// call-graph context: aggregate shape of this function's callees (a callee's structure is
|
||||
// build-invariant), which distinguishes otherwise-identical thunks by which function they hit.
|
||||
pub ctx_ins: u32,
|
||||
pub ctx_calls: u32,
|
||||
pub ctx_br: u32,
|
||||
pub cats: [u32; NCATS],
|
||||
// content sketch: hash-bucket histogram of the printable rodata strings this function
|
||||
// references. The string *content* is build-invariant and highly function-specific, so it adds
|
||||
// discriminative signal the pure structural counts lack.
|
||||
pub refs: [u32; NREF],
|
||||
}
|
||||
|
||||
impl Fingerprint {
|
||||
pub fn to_vec(&self) -> Vec<u32> {
|
||||
let mut v = vec![
|
||||
self.size_bytes,
|
||||
self.insns,
|
||||
self.blocks,
|
||||
self.calls,
|
||||
self.distinct_callees,
|
||||
self.cond_branches,
|
||||
self.uncond_branches,
|
||||
self.rets,
|
||||
self.data_refs,
|
||||
self.indirect,
|
||||
self.ctx_ins,
|
||||
self.ctx_calls,
|
||||
self.ctx_br,
|
||||
];
|
||||
v.extend_from_slice(&self.cats);
|
||||
v.extend_from_slice(&self.refs);
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
/// If `target` points at a printable C string in read-only data, hash it into a `[0, NREF)` bucket.
|
||||
/// Only strings of a few printable chars count — this skips jump tables and pointer arrays (whose
|
||||
/// bytes are addresses that move across builds), keeping the sketch build-invariant.
|
||||
fn string_bucket(img: &CodeImage, target: u64) -> Option<usize> {
|
||||
let bytes = img.rodata_at(target, 32)?;
|
||||
let run: &[u8] = {
|
||||
let end = bytes
|
||||
.iter()
|
||||
.position(|&c| !(0x20..0x7f).contains(&c))
|
||||
.unwrap_or(bytes.len());
|
||||
&bytes[..end]
|
||||
};
|
||||
if run.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
// FNV-1a over the printable run
|
||||
let mut h: u64 = 0xcbf29ce484222325;
|
||||
for &b in run {
|
||||
h = (h ^ b as u64).wrapping_mul(0x100000001b3);
|
||||
}
|
||||
Some((h % NREF as u64) as usize)
|
||||
}
|
||||
|
||||
fn category(insn: &Instruction) -> usize {
|
||||
match insn.flow_control() {
|
||||
FlowControl::Call | FlowControl::IndirectCall => return 10,
|
||||
FlowControl::UnconditionalBranch | FlowControl::IndirectBranch => return 11,
|
||||
FlowControl::ConditionalBranch => return 12,
|
||||
FlowControl::Return => return 13,
|
||||
_ => {}
|
||||
}
|
||||
if insn.op0_register().is_xmm()
|
||||
|| insn.op0_register().is_ymm()
|
||||
|| insn.op1_register().is_xmm()
|
||||
|| insn.op1_register().is_ymm()
|
||||
{
|
||||
return 17;
|
||||
}
|
||||
use Mnemonic::*;
|
||||
match insn.mnemonic() {
|
||||
Mov | Movzx | Movsx | Movsxd | Xchg => 0,
|
||||
Lea => 1,
|
||||
Push => 2,
|
||||
Pop => 3,
|
||||
Add | Adc | Sub | Sbb | Inc | Dec | Neg => 4,
|
||||
Imul | Mul | Idiv | Div => 5,
|
||||
And | Or | Xor | Not => 6,
|
||||
Shl | Shr | Sar | Rol | Ror | Shld | Shrd => 7,
|
||||
Cmp => 8,
|
||||
Test => 9,
|
||||
Nop | Int3 => 14,
|
||||
Leave => 15,
|
||||
_ => 16,
|
||||
}
|
||||
}
|
||||
|
||||
fn near_target(insn: &Instruction) -> Option<u64> {
|
||||
match insn.op0_kind() {
|
||||
OpKind::NearBranch16 | OpKind::NearBranch32 | OpKind::NearBranch64 => {
|
||||
Some(insn.near_branch_target())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn in_span(t: u64, entry: u64, cap: usize) -> bool {
|
||||
t >= entry && (t - entry) as usize <= cap
|
||||
}
|
||||
|
||||
struct Base {
|
||||
f: Fingerprint,
|
||||
targets: Vec<u64>, // outgoing call + tail-jump destinations
|
||||
}
|
||||
|
||||
/// Cheap, build-invariant summary of a callee — (instructions, calls, branches) over a bounded
|
||||
/// linear scan from its entry. Enough to tell one thunk's target from another's.
|
||||
fn light_summary(img: &CodeImage, addr: u64) -> (u32, u32, u32) {
|
||||
let Some(code) = img.code_at(addr) else {
|
||||
return (0, 0, 0);
|
||||
};
|
||||
let mut dec = Decoder::with_ip(64, code, addr, DecoderOptions::NONE);
|
||||
let (mut ins, mut calls, mut br) = (0u32, 0u32, 0u32);
|
||||
while dec.can_decode() && ins < 48 {
|
||||
let insn = dec.decode();
|
||||
if insn.len() == 0 || insn.is_invalid() {
|
||||
break;
|
||||
}
|
||||
ins += 1;
|
||||
match insn.flow_control() {
|
||||
FlowControl::Call | FlowControl::IndirectCall => calls += 1,
|
||||
FlowControl::ConditionalBranch
|
||||
| FlowControl::UnconditionalBranch
|
||||
| FlowControl::IndirectBranch => br += 1,
|
||||
FlowControl::Return => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(ins, calls, br)
|
||||
}
|
||||
|
||||
/// Extract the fingerprint of `entry` — base structure plus one hop of call-graph context.
|
||||
pub fn extract(img: &CodeImage, entry: u64) -> Option<Fingerprint> {
|
||||
let base = extract_base(img, entry)?;
|
||||
let mut f = base.f;
|
||||
let mut seen = HashSet::new();
|
||||
for &t in &base.targets {
|
||||
if t != entry && img.is_code(t) && seen.insert(t) {
|
||||
let (ins, calls, br) = light_summary(img, t);
|
||||
f.ctx_ins = f.ctx_ins.wrapping_add(ins);
|
||||
f.ctx_calls = f.ctx_calls.wrapping_add(calls);
|
||||
f.ctx_br = f.ctx_br.wrapping_add(br);
|
||||
}
|
||||
}
|
||||
Some(f)
|
||||
}
|
||||
|
||||
/// Base structural features + the function's outgoing call/tail-jump targets.
|
||||
fn extract_base(img: &CodeImage, entry: u64) -> Option<Base> {
|
||||
let code = img.code_at(entry)?;
|
||||
const MAX_SPAN: usize = 96 * 1024;
|
||||
const MAX_INSNS: u32 = 8000;
|
||||
let cap = code.len().min(MAX_SPAN);
|
||||
|
||||
let mut f = Fingerprint::default();
|
||||
let mut visited: HashSet<u64> = HashSet::new();
|
||||
let mut leaders: HashSet<u64> = HashSet::new();
|
||||
let mut callees: HashSet<u64> = HashSet::new();
|
||||
let mut targets: Vec<u64> = Vec::new();
|
||||
let mut hi = entry;
|
||||
let mut work = vec![entry];
|
||||
leaders.insert(entry);
|
||||
|
||||
while let Some(start) = work.pop() {
|
||||
let mut ip = start;
|
||||
loop {
|
||||
if ip < entry || (ip - entry) as usize >= cap || f.insns >= MAX_INSNS {
|
||||
break;
|
||||
}
|
||||
if !visited.insert(ip) {
|
||||
break; // this path merged into already-decoded code
|
||||
}
|
||||
let off = (ip - entry) as usize;
|
||||
let mut dec = Decoder::with_ip(64, &code[off..], ip, DecoderOptions::NONE);
|
||||
if !dec.can_decode() {
|
||||
break;
|
||||
}
|
||||
let insn = dec.decode();
|
||||
let ilen = insn.len() as u64;
|
||||
if ilen == 0 || insn.is_invalid() {
|
||||
break;
|
||||
}
|
||||
f.insns += 1;
|
||||
f.cats[category(&insn)] += 1;
|
||||
if insn.is_ip_rel_memory_operand() {
|
||||
f.data_refs += 1;
|
||||
if let Some(bucket) = string_bucket(img, insn.memory_displacement64()) {
|
||||
f.refs[bucket] += 1;
|
||||
}
|
||||
}
|
||||
if ip + ilen > hi {
|
||||
hi = ip + ilen;
|
||||
}
|
||||
let next = ip + ilen;
|
||||
match insn.flow_control() {
|
||||
FlowControl::Return => {
|
||||
f.rets += 1;
|
||||
break;
|
||||
}
|
||||
FlowControl::Call => {
|
||||
f.calls += 1;
|
||||
match near_target(&insn) {
|
||||
Some(t) => {
|
||||
callees.insert(t);
|
||||
targets.push(t);
|
||||
}
|
||||
None => f.indirect += 1,
|
||||
}
|
||||
ip = next;
|
||||
}
|
||||
FlowControl::IndirectCall => {
|
||||
f.calls += 1;
|
||||
f.indirect += 1;
|
||||
ip = next;
|
||||
}
|
||||
FlowControl::ConditionalBranch => {
|
||||
f.cond_branches += 1;
|
||||
if let Some(t) = near_target(&insn)
|
||||
&& in_span(t, entry, cap)
|
||||
{
|
||||
leaders.insert(t);
|
||||
work.push(t);
|
||||
}
|
||||
leaders.insert(next);
|
||||
ip = next;
|
||||
}
|
||||
FlowControl::UnconditionalBranch => {
|
||||
f.uncond_branches += 1;
|
||||
match near_target(&insn) {
|
||||
Some(t) if in_span(t, entry, cap) => {
|
||||
leaders.insert(t);
|
||||
ip = t;
|
||||
}
|
||||
Some(t) => {
|
||||
targets.push(t); // tail call (thunk target)
|
||||
break;
|
||||
}
|
||||
None => break, // indirect jump
|
||||
}
|
||||
}
|
||||
FlowControl::IndirectBranch => {
|
||||
f.indirect += 1;
|
||||
break;
|
||||
}
|
||||
_ => ip = next,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if f.insns == 0 {
|
||||
return None;
|
||||
}
|
||||
f.blocks = leaders.len() as u32;
|
||||
f.distinct_callees = callees.len() as u32;
|
||||
f.size_bytes = (hi - entry) as u32;
|
||||
Some(Base { f, targets })
|
||||
}
|
||||
47
src/lib.rs
Normal file
47
src/lib.rs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
//! source2rosetta — library crate: the reusable derivation/verification engine behind the CLI.
|
||||
//!
|
||||
//! The `source2rosetta` binary (`src/main.rs`) is a thin clap front-end over these modules. Keeping the
|
||||
//! logic in a library lets the CI pipeline (and tests) link and call it directly instead of shelling
|
||||
//! 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):
|
||||
//! - [`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.
|
||||
//! - [`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`
|
||||
//! (the CI branch primitives), `unpack_seed`.
|
||||
//! - [`profile`] — the per-game knobs: [`profile::GameProfile`] plus the `CS2` / `DOTA` consts. Adding a game is a const here.
|
||||
//! - [`model`] / [`render`] (re-exported from `source2rosetta-core`) — the canonical derived-gamedata model and its
|
||||
//! format emitters; the standalone `source2rosetta-gen` binary links just `core`.
|
||||
//!
|
||||
//! # Low-level engine (implementation detail)
|
||||
//! The modules below are the building blocks the API composes (ELF/RTTI/SchemaSystem readers, the fingerprint
|
||||
//! metric, the sig/abi machinery, the data-parallel primitive, the name taxonomy). They stay `pub` for the fuzz
|
||||
//! harness and advanced embedders, but carry NO stability promise — treat them as internal.
|
||||
|
||||
// ---- supported API ----
|
||||
pub mod pipeline;
|
||||
pub mod produce;
|
||||
pub mod profile;
|
||||
|
||||
// ---- low-level engine (implementation detail; `pub` only for the fuzz harness, not a stable surface) ----
|
||||
pub mod abi;
|
||||
pub mod elf;
|
||||
pub mod emit;
|
||||
pub mod fingerprint;
|
||||
pub mod live;
|
||||
pub mod locate;
|
||||
pub mod par;
|
||||
pub mod rtti;
|
||||
pub mod schema;
|
||||
pub mod sig;
|
||||
pub mod taxonomy;
|
||||
pub mod xref;
|
||||
|
||||
// The canonical model + emitters live in the deriver-free `source2rosetta-core` crate; re-export them so
|
||||
// existing `source2rosetta::{model, render}` paths keep resolving.
|
||||
pub use source2rosetta_core::{model, render};
|
||||
310
src/live.rs
Normal file
310
src/live.rs
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
//! 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`).
|
||||
//!
|
||||
//! 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
|
||||
//! confirms both our relocation logic and the struct layout — and, because runtime-populated fields
|
||||
//! (e.g. `m_pSchemaBinding`) are non-null live but zero on disk, proves we are reading live state.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::collections::HashMap;
|
||||
use std::fs::File;
|
||||
use std::os::unix::fs::FileExt;
|
||||
|
||||
pub struct LiveProcess {
|
||||
mem: File,
|
||||
bases: HashMap<String, u64>, // library filename -> load base (lowest mapping address)
|
||||
paths: HashMap<String, String>, // library filename -> the FULL path the process actually mapped
|
||||
writable: Vec<(u64, u64)>, // rw anonymous regions (heap etc.) — where live objects live
|
||||
executable: Vec<(u64, u64)>, // r-x regions — where valid code/vtable-slot targets must land
|
||||
}
|
||||
|
||||
impl LiveProcess {
|
||||
pub fn attach(pid: u32) -> Result<Self> {
|
||||
let maps = std::fs::read_to_string(format!("/proc/{pid}/maps"))
|
||||
.with_context(|| format!("read /proc/{pid}/maps (is pid {pid} running?)"))?;
|
||||
let mut bases: HashMap<String, u64> = HashMap::new();
|
||||
let mut paths: HashMap<String, String> = HashMap::new();
|
||||
let mut writable: Vec<(u64, u64)> = Vec::new();
|
||||
let mut executable: Vec<(u64, u64)> = Vec::new();
|
||||
for line in maps.lines() {
|
||||
// format: START-END perms offset dev inode path
|
||||
let (range, rest) = match line.split_once(' ') {
|
||||
Some(x) => x,
|
||||
None => continue,
|
||||
};
|
||||
let perms = rest.split(' ').next().unwrap_or("");
|
||||
let path = line.rsplit_once(char::is_whitespace).map_or("", |(_, p)| p);
|
||||
let Some((start, end)) = range.split_once('-').and_then(|(a, b)| {
|
||||
Some((
|
||||
u64::from_str_radix(a, 16).ok()?,
|
||||
u64::from_str_radix(b, 16).ok()?,
|
||||
))
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
if path.ends_with(".so") && path.starts_with('/') {
|
||||
let fname = path.rsplit('/').next().unwrap_or(path).to_string();
|
||||
paths
|
||||
.entry(fname.clone())
|
||||
.or_insert_with(|| path.to_string());
|
||||
bases
|
||||
.entry(fname)
|
||||
.and_modify(|b| *b = (*b).min(start))
|
||||
.or_insert(start);
|
||||
}
|
||||
// writable anonymous memory = the heap where runtime objects (entities) are allocated
|
||||
if perms.starts_with("rw") && (path.is_empty() || path == "[heap]") {
|
||||
writable.push((start, end));
|
||||
}
|
||||
if perms.starts_with('r') && perms.contains('x') {
|
||||
executable.push((start, end));
|
||||
}
|
||||
}
|
||||
executable.sort_unstable();
|
||||
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)"
|
||||
)
|
||||
})?;
|
||||
Ok(Self {
|
||||
mem,
|
||||
bases,
|
||||
paths,
|
||||
writable,
|
||||
executable,
|
||||
})
|
||||
}
|
||||
|
||||
/// Is `addr` inside an executable mapping? A valid function pointer / vtable slot target must be.
|
||||
pub fn is_exec(&self, addr: u64) -> bool {
|
||||
self.executable
|
||||
.binary_search_by(|&(s, e)| {
|
||||
if addr < s {
|
||||
std::cmp::Ordering::Greater
|
||||
} else if addr >= e {
|
||||
std::cmp::Ordering::Less
|
||||
} else {
|
||||
std::cmp::Ordering::Equal
|
||||
}
|
||||
})
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// Best-effort read of `n` bytes at runtime `addr` (short/empty on an unmapped page).
|
||||
pub fn read_bytes(&self, addr: u64, n: usize) -> Vec<u8> {
|
||||
let mut buf = vec![0u8; n];
|
||||
let got = self.mem.read_at(&mut buf, addr).unwrap_or(0);
|
||||
buf.truncate(got);
|
||||
buf
|
||||
}
|
||||
|
||||
/// Scan the writable/heap regions for object instances whose vtable pointer is `vtable` — i.e.
|
||||
/// live instances of the class that owns that vtable. Returns the object base addresses (an
|
||||
/// object's first qword is its vtable pointer). Stops at `max` hits.
|
||||
pub fn find_instances(&self, vtable: u64, max: usize) -> Vec<u64> {
|
||||
let mut hits = Vec::new();
|
||||
let mut buf = vec![0u8; 1 << 20]; // 1 MiB window
|
||||
let needle = vtable.to_le_bytes();
|
||||
'outer: for &(start, end) in &self.writable {
|
||||
let mut addr = start;
|
||||
while addr < end {
|
||||
let n = ((end - addr) as usize).min(buf.len());
|
||||
// `buf` is reused across windows, so scanning past a SHORT read matches stale bytes from the
|
||||
// previous window and reports addresses that hold nothing of the sort. Bind both the scan and
|
||||
// the advance to what was actually read.
|
||||
let got = self.mem.read_at(&mut buf[..n], addr).unwrap_or(0);
|
||||
if got < 8 {
|
||||
addr += n as u64;
|
||||
continue;
|
||||
}
|
||||
// objects are pointer-aligned, so only 8-aligned positions can be a vtable slot
|
||||
let mut i = 0;
|
||||
while i + 8 <= got {
|
||||
if buf[i..i + 8] == needle {
|
||||
hits.push(addr + i as u64);
|
||||
if hits.len() >= max {
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
i += 8;
|
||||
}
|
||||
// advance by the 8-aligned prefix consumed: a persistently short-reading region still makes
|
||||
// progress (never re-reads the same bytes), and the skipped tail is retried on the next pass.
|
||||
addr += (got & !7) as u64;
|
||||
}
|
||||
}
|
||||
hits
|
||||
}
|
||||
|
||||
/// Load base (slide) of a library — its lowest mapping address. Since CS2 `.so` files link at
|
||||
/// vaddr 0, the runtime address of a file vaddr `v` is simply `base + v`.
|
||||
pub fn base(&self, lib: &str) -> Option<u64> {
|
||||
self.bases.get(lib).copied()
|
||||
}
|
||||
|
||||
/// The full path the process actually mapped for `lib` (a basename). The authority on WHICH file of a
|
||||
/// given name is loaded when several exist on disk — a game tree can hold the engine's own
|
||||
/// `libserver.so` and a loader shim of the same name several directories away.
|
||||
pub fn mapped_path(&self, lib: &str) -> Option<&str> {
|
||||
self.paths.get(lib).map(String::as_str)
|
||||
}
|
||||
|
||||
fn read(&self, addr: u64, buf: &mut [u8]) -> Result<()> {
|
||||
self.mem
|
||||
.read_exact_at(buf, addr)
|
||||
.with_context(|| format!("read {} bytes at {addr:#x}", buf.len()))
|
||||
}
|
||||
|
||||
pub fn read_u64(&self, addr: u64) -> Result<u64> {
|
||||
let mut b = [0u8; 8];
|
||||
self.read(addr, &mut b)?;
|
||||
Ok(u64::from_le_bytes(b))
|
||||
}
|
||||
|
||||
pub fn read_i32(&self, addr: u64) -> Result<i32> {
|
||||
let mut b = [0u8; 4];
|
||||
self.read(addr, &mut b)?;
|
||||
Ok(i32::from_le_bytes(b))
|
||||
}
|
||||
|
||||
pub fn read_u16(&self, addr: u64) -> Result<u16> {
|
||||
let mut b = [0u8; 2];
|
||||
self.read(addr, &mut b)?;
|
||||
Ok(u16::from_le_bytes(b))
|
||||
}
|
||||
|
||||
/// NUL-terminated string at runtime `addr` (bounded). Reads may land near an unmapped page, so a
|
||||
/// short read is fine — we take whatever came back up to the terminator.
|
||||
pub fn read_cstr(&self, addr: u64) -> Result<String> {
|
||||
let mut buf = [0u8; 256];
|
||||
let n = self.mem.read_at(&mut buf, addr).unwrap_or(0);
|
||||
let end = buf[..n].iter().position(|&c| c == 0).unwrap_or(n);
|
||||
Ok(String::from_utf8_lossy(&buf[..end]).into_owned())
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a remote call: the return value (RAX) and whether the function returned cleanly to our
|
||||
/// trap (vs faulting internally on a bad argument).
|
||||
pub struct CallResult {
|
||||
pub rax: u64,
|
||||
pub clean_return: bool,
|
||||
}
|
||||
|
||||
/// Call the function at runtime address `func` inside process `pid` with `args` (SysV: up to 6 in
|
||||
/// registers), via ptrace. Attaches, saves the main thread's registers, sets up a call frame whose
|
||||
/// return address is 0 (so the function traps on return, where we read RAX), runs it, then restores
|
||||
/// the thread exactly — the SIGSEGV from the return trap is suppressed. Needs ptrace permission
|
||||
/// (owned child, or same-user with ptrace_scope=0). UNSAFE: only call leaf-ish functions with valid args.
|
||||
pub fn call_remote(pid: i32, func: u64, args: &[u64]) -> Result<CallResult> {
|
||||
use anyhow::bail;
|
||||
let dbg = std::env::var("SOURCE2ROSETTA_DBG").is_ok();
|
||||
unsafe {
|
||||
if libc::ptrace(libc::PTRACE_ATTACH, pid, 0usize, 0usize) < 0 {
|
||||
bail!(
|
||||
"PTRACE_ATTACH {pid} failed (errno {}) — need ptrace permission",
|
||||
errno()
|
||||
);
|
||||
}
|
||||
let mut status = 0i32;
|
||||
if libc::waitpid(pid, &mut status, 0) < 0 {
|
||||
libc::ptrace(libc::PTRACE_DETACH, pid, 0usize, 0usize);
|
||||
bail!("waitpid(attach) failed");
|
||||
}
|
||||
if dbg {
|
||||
eprintln!(
|
||||
"[call] attached; stop status {status:#x} (stopped={})",
|
||||
libc::WIFSTOPPED(status)
|
||||
);
|
||||
}
|
||||
let mut saved: libc::user_regs_struct = std::mem::zeroed();
|
||||
if libc::ptrace(libc::PTRACE_GETREGS, pid, 0usize, &mut saved as *mut _) < 0 {
|
||||
libc::ptrace(libc::PTRACE_DETACH, pid, 0usize, 0usize);
|
||||
bail!("PTRACE_GETREGS failed");
|
||||
}
|
||||
let restore = |saved: &libc::user_regs_struct| {
|
||||
libc::ptrace(libc::PTRACE_SETREGS, pid, 0usize, saved as *const _);
|
||||
libc::ptrace(libc::PTRACE_DETACH, pid, 0usize, 0usize);
|
||||
};
|
||||
|
||||
let mut regs = saved;
|
||||
// If we attached mid-syscall, orig_rax holds the syscall number and the kernel would run its
|
||||
// syscall-restart logic on our injected rip. Setting it to -1 says "no syscall in progress".
|
||||
regs.orig_rax = u64::MAX;
|
||||
let slots = [
|
||||
&mut regs.rdi as *mut u64,
|
||||
&mut regs.rsi,
|
||||
&mut regs.rdx,
|
||||
&mut regs.rcx,
|
||||
&mut regs.r8,
|
||||
&mut regs.r9,
|
||||
];
|
||||
for (i, &a) in args.iter().take(6).enumerate() {
|
||||
*slots[i] = a;
|
||||
}
|
||||
// Scratch stack BELOW the 128-byte redzone so we never corrupt the interrupted frame; write a
|
||||
// return address of 0 and keep SysV's `rsp % 16 == 8` at function entry.
|
||||
let mut sp = (saved.rsp - 512) & !0xfu64;
|
||||
sp -= 8;
|
||||
if libc::ptrace(libc::PTRACE_POKEDATA, pid, sp as usize, 0usize) < 0 {
|
||||
restore(&saved);
|
||||
bail!(
|
||||
"POKEDATA(return addr) at {sp:#x} failed (errno {})",
|
||||
errno()
|
||||
);
|
||||
}
|
||||
let wrote = libc::ptrace(libc::PTRACE_PEEKDATA, pid, sp as usize, 0usize);
|
||||
regs.rsp = sp;
|
||||
regs.rip = func;
|
||||
if libc::ptrace(libc::PTRACE_SETREGS, pid, 0usize, ®s as *const _) < 0 {
|
||||
restore(&saved);
|
||||
bail!("PTRACE_SETREGS failed");
|
||||
}
|
||||
if dbg {
|
||||
eprintln!(
|
||||
"[call] rip={func:#x} rsp={sp:#x} rdi={:#x} retaddr-slot={wrote:#x} (want 0)",
|
||||
regs.rdi
|
||||
);
|
||||
}
|
||||
|
||||
// Run, absorbing any spurious signals, until the function returns into our null trap.
|
||||
loop {
|
||||
libc::ptrace(libc::PTRACE_CONT, pid, 0usize, 0usize);
|
||||
if libc::waitpid(pid, &mut status, 0) < 0 || !libc::WIFSTOPPED(status) {
|
||||
restore(&saved);
|
||||
bail!("target vanished mid-call (status {status:#x})");
|
||||
}
|
||||
let sig = libc::WSTOPSIG(status);
|
||||
let mut cur: libc::user_regs_struct = std::mem::zeroed();
|
||||
libc::ptrace(libc::PTRACE_GETREGS, pid, 0usize, &mut cur as *mut _);
|
||||
if dbg {
|
||||
eprintln!(
|
||||
"[call] stop sig={sig} rip={:#x} rax={:#x}",
|
||||
cur.rip, cur.rax
|
||||
);
|
||||
}
|
||||
if cur.rip == 0 {
|
||||
let r = CallResult {
|
||||
rax: cur.rax,
|
||||
clean_return: true,
|
||||
};
|
||||
restore(&saved);
|
||||
return Ok(r);
|
||||
}
|
||||
if sig == libc::SIGSEGV || sig == libc::SIGILL || sig == libc::SIGBUS {
|
||||
let r = CallResult {
|
||||
rax: cur.rax,
|
||||
clean_return: false,
|
||||
};
|
||||
restore(&saved);
|
||||
return Ok(r);
|
||||
}
|
||||
// any other signal (SIGSTOP/timer/…): swallow it and keep running the call
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn errno() -> i32 {
|
||||
unsafe { *libc::__errno_location() }
|
||||
}
|
||||
95
src/locate.rs
Normal file
95
src/locate.rs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
//! Locating primitives: where a library file lives ON DISK, and where the function entries live
|
||||
//! INSIDE an image.
|
||||
//!
|
||||
//! On disk: `find_file` resolves a lib by name under a build tree (nearest-depth-wins, so a Metamod
|
||||
//! shim can't shadow the real engine lib) and `load_lib` turns a build dir *or* a bare `.so` into a
|
||||
//! loaded [`CodeImage`]. Both are leaf primitives (they touch only `elf` + the filesystem), so the
|
||||
//! low-level readers — `schema` especially — depend on THIS module rather than up on the engine.
|
||||
//!
|
||||
//! In an image: [`candidate_entries`] enumerates plausible function starts without symbols — relocation
|
||||
//! values that point into code (vtable slots + function pointers — covers virtual functions) unioned
|
||||
//! with the targets of direct near `call`s found by a linear sweep. `xref` unions this with `.eh_frame`
|
||||
//! starts to index the whole binary.
|
||||
|
||||
use crate::elf::CodeImage;
|
||||
use anyhow::{Context, Result};
|
||||
use iced_x86::{Decoder, DecoderOptions, FlowControl, OpKind};
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// 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> {
|
||||
let mut set: BTreeSet<u64> = img.code_pointer_targets().into_iter().collect();
|
||||
for (va, code) in img.exec_blocks() {
|
||||
let mut dec = Decoder::with_ip(64, code, va, DecoderOptions::NONE);
|
||||
while dec.can_decode() {
|
||||
let insn = dec.decode(); // iced advances one byte on invalid, so the sweep self-resyncs
|
||||
if insn.flow_control() == FlowControl::Call
|
||||
&& matches!(
|
||||
insn.op0_kind(),
|
||||
OpKind::NearBranch16 | OpKind::NearBranch32 | OpKind::NearBranch64
|
||||
)
|
||||
{
|
||||
let t = insn.near_branch_target();
|
||||
if img.is_code(t) {
|
||||
set.insert(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
set.into_iter().collect()
|
||||
}
|
||||
|
||||
/// Shallowest file named `name` under `dir` (bounded depth), ties broken by sorted path.
|
||||
///
|
||||
/// NEAREST-DEPTH-WINS, not first-`read_dir`-hit: a game install legitimately holds several files of the same
|
||||
/// basename, and the shallowest is the real one. A CS2 tree has the engine's own
|
||||
/// `csgo/bin/linuxsteamrt64/libserver.so` at depth 3 and Metamod's ~300 KB loader shim of the SAME name at
|
||||
/// `csgo/addons/metamod/bin/linuxsteamrt64/libserver.so` (depth 5, plus any `bin.*.bak` siblings). Depth-first
|
||||
/// order made which one you derive from a property of directory-entry order — deriving against the shim would
|
||||
/// yield garbage — and plain sorting is WORSE, since `addons` sorts before `bin`. Sorting is only the tie-break
|
||||
/// among equally-shallow candidates, so the result never depends on filesystem enumeration order.
|
||||
pub(crate) fn find_file(dir: &Path, name: &str, depth: usize) -> Option<PathBuf> {
|
||||
let mut level = vec![dir.to_path_buf()];
|
||||
for _ in 0..depth {
|
||||
let (mut hits, mut next) = (Vec::new(), Vec::new());
|
||||
for d in &level {
|
||||
let Ok(rd) = std::fs::read_dir(d) else {
|
||||
continue;
|
||||
};
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
next.push(p);
|
||||
} else if p.file_name().and_then(|s| s.to_str()) == Some(name) {
|
||||
hits.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !hits.is_empty() {
|
||||
hits.sort();
|
||||
return hits.into_iter().next();
|
||||
}
|
||||
if next.is_empty() {
|
||||
return None;
|
||||
}
|
||||
next.sort();
|
||||
level = next;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Locate `lib` under `dir` (depth 8) and load it as a `CodeImage` — the `find_file` + load pattern the
|
||||
/// command entry points share.
|
||||
pub(crate) fn load_lib(path: &Path, lib: &str) -> Result<CodeImage> {
|
||||
// Accept a build DIR (find `lib` within, depth 8) or a direct `.so` FILE (load as-is), so callers can
|
||||
// pass `path/to/build_dir` or `path/to/libserver.so` interchangeably (e.g. `classify-change --prev`).
|
||||
let file = if path.is_file() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
find_file(path, lib, 8)
|
||||
.with_context(|| format!("{lib} not found under {}", path.display()))?
|
||||
};
|
||||
CodeImage::load(&file)
|
||||
}
|
||||
482
src/main.rs
Normal file
482
src/main.rs
Normal file
|
|
@ -0,0 +1,482 @@
|
|||
//! source2rosetta — CLI front-end. A thin clap layer over `source2rosetta::pipeline`: parse args,
|
||||
//! select the game profile, dispatch to the engine.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
use source2rosetta::pipeline::{
|
||||
ClassScope, backfill_cmd, corpus_model_cmd, fold_model_cmd, load_model,
|
||||
};
|
||||
use source2rosetta::produce::{
|
||||
ProduceArgs, SeedInputs, classify_change_cmd, filter_corpus_cmd, integration_test_cmd,
|
||||
produce_cmd, unpack_seed,
|
||||
};
|
||||
use source2rosetta::profile;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// The game whose profile drives lib/pawn/launch/dead-weight knobs. Adding a game = a profile const + an arm.
|
||||
#[derive(Clone, Copy, clap::ValueEnum)]
|
||||
enum Game {
|
||||
#[value(alias = "csgo")]
|
||||
Cs2,
|
||||
#[value(alias = "dota")]
|
||||
Dota2,
|
||||
}
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "source2rosetta",
|
||||
about = "Locate Source-2 engine functions across builds"
|
||||
)]
|
||||
struct Cli {
|
||||
/// Which game's profile to use — selects lib/pawn/launch/game-key/dead-weight knobs. Source-2-generic
|
||||
/// behavior is unaffected; only the game-specific paths read the selected profile. (Known: cs2, dota2.)
|
||||
#[arg(long, global = true, value_enum, default_value = "cs2")]
|
||||
game: Game,
|
||||
#[command(subcommand)]
|
||||
cmd: Cmd,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Own the process end-to-end for CI — no human, no mod: LAUNCH a VANILLA dedicated server for the
|
||||
/// selected `--game`, populate it (CS2: bots on an empty deathmatch; a pawn-less game like Dota waits
|
||||
/// for its `ready_class` proxy instead), then verify the derived gamedata against it — the schema oracle,
|
||||
/// a semantic ptrace CALL on a live pawn (pawn games only), and (with --gamedata) a full validate-live.
|
||||
/// (Disable metamod in the game's `gameinfo.gi` for a truly vanilla run — no hooks, clean pass/fail.)
|
||||
IntegrationTest {
|
||||
/// Game root (contains `bin/linuxsteamrt64/<executable>` and the content dir).
|
||||
#[arg(long = "game-dir")]
|
||||
game_dir: PathBuf,
|
||||
/// Dir holding the on-disk libserver.so for the offline reference (defaults to --game).
|
||||
#[arg(long)]
|
||||
build: Option<PathBuf>,
|
||||
/// 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.
|
||||
#[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.
|
||||
#[arg(long, default_value_t = 9)]
|
||||
bots: u32,
|
||||
/// Optional gamedata json to also validate-live against the running server.
|
||||
#[arg(long)]
|
||||
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)]
|
||||
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). Reuses the launched server — no separate `fuzz-live` run needed for CI.
|
||||
#[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).**
|
||||
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.
|
||||
#[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).
|
||||
#[arg(long)]
|
||||
build: Option<PathBuf>,
|
||||
/// Server library to derive from; defaults to the active game's server lib.
|
||||
#[arg(long)]
|
||||
lib: Option<String>,
|
||||
/// One bundled seed (catalogue + naming sections) — the release form. Replaces the loose
|
||||
/// --catalogue/--promotable/--candidates/--full-names/--extra-offsets/--extra-sigs flags.
|
||||
#[arg(long)]
|
||||
seed: Option<PathBuf>,
|
||||
/// Function catalogue (loose form; omit when using --seed).
|
||||
#[arg(long)]
|
||||
catalogue: Option<PathBuf>,
|
||||
/// Corpus-signal source A: the raw build binaries to fingerprint on the fly. Exactly ONE of
|
||||
/// --corpus / --corpus-model is required (--corpus-model is the production forward-derive path).
|
||||
#[arg(long)]
|
||||
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)]
|
||||
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.
|
||||
#[arg(long)]
|
||||
target: PathBuf,
|
||||
/// Optional: names eligible for promotion into high_confidence (from the naming producer flow).
|
||||
/// Omit to promote nothing — the catalogue still derives in full.
|
||||
#[arg(long)]
|
||||
promotable: Option<PathBuf>,
|
||||
/// Optional: prefiltered per-address context for those names (`{"candidates": [...]}`). Omit for none.
|
||||
#[arg(long)]
|
||||
candidates: Option<PathBuf>,
|
||||
/// Optional: the full-slice name universe. When set, the monolith also carries an `experimental`
|
||||
/// tier — the least-filtered inclusion band (every name guess, graded, each with a resolvable
|
||||
/// locator but an UNVERIFIED name).
|
||||
#[arg(long)]
|
||||
full_names: Option<PathBuf>,
|
||||
/// Multilib ground-truth vtable offsets to fold as high_confidence — `{lib: [{name,class,slot}]}`
|
||||
/// (e.g. the macOS symbol transfer). Folded directly, bypassing the candidate gate.
|
||||
#[arg(long)]
|
||||
extra_offsets: Option<PathBuf>,
|
||||
/// Multilib non-virtual names to fold as sigs — `{lib: [{name,addr}]}`; `make_sig` runs per lib.
|
||||
#[arg(long)]
|
||||
extra_sigs: Option<PathBuf>,
|
||||
/// Byte budget for signatures the FOLD generates (the extrapolated tiers). The derive's own
|
||||
/// `core` sigs use a separate fixed budget — this flag does not widen those.
|
||||
#[arg(long, default_value_t = 400)]
|
||||
sig_cap: usize,
|
||||
#[arg(long, default_value = "vX")]
|
||||
version: String,
|
||||
#[arg(long)]
|
||||
out_dir: PathBuf,
|
||||
/// Class scope for the sidecar model fold — must match the scope the input model was distilled with.
|
||||
#[arg(long, value_enum, default_value = "clean")]
|
||||
class_scope: ClassScope,
|
||||
#[arg(long, default_value_t = 90)]
|
||||
wait: u64,
|
||||
#[arg(long)] // default resolved from the active game profile at dispatch
|
||||
map: Option<String>,
|
||||
#[arg(long, default_value_t = 9)]
|
||||
bots: u32,
|
||||
},
|
||||
/// Distill the whole corpus into a shippable model (vtable-alignment hops + reference fingerprints
|
||||
/// + slot timelines) so derivation needs only the model + the target binary, not the 86 GB corpus.
|
||||
CorpusModel {
|
||||
/// One bundled seed — the release form; its catalogue section is what gets distilled. Replaces the
|
||||
/// loose --catalogue (naming sections are ignored here — the model tracks catalogue names only).
|
||||
#[arg(long)]
|
||||
seed: Option<PathBuf>,
|
||||
/// Function catalogue (loose form; omit when using --seed).
|
||||
#[arg(long)]
|
||||
catalogue: Option<PathBuf>,
|
||||
#[arg(long)]
|
||||
corpus: PathBuf,
|
||||
/// Which classes get vtable-slot hops: `clean` (every real game class — the default; enough for any
|
||||
/// modding offset to derive model-only), `all` (also template/protobuf/NetworkVar junk), or
|
||||
/// `catalogue` (only what the catalogue names). CI compresses the model, so on-disk size isn't shipped.
|
||||
#[arg(long, value_enum, default_value = "clean")]
|
||||
class_scope: ClassScope,
|
||||
#[arg(long)]
|
||||
out: PathBuf,
|
||||
},
|
||||
/// Incrementally fold ONE new build into an existing model: `model N + build → model N+1`, equal to a
|
||||
/// full re-distill over the same builds but reading only the model + the one binary (no corpus). The
|
||||
/// production update path — keeps the model fresh per build without re-reading history.
|
||||
FoldModel {
|
||||
/// The existing model N (carries the `abi_obs` window the fold re-windows).
|
||||
#[arg(long)]
|
||||
model: PathBuf,
|
||||
/// One bundled seed — the release form; its catalogue section is folded. Replaces the loose --catalogue.
|
||||
#[arg(long)]
|
||||
seed: Option<PathBuf>,
|
||||
/// Function catalogue (loose form; omit when using --seed). Must match the model's distill catalogue.
|
||||
#[arg(long)]
|
||||
catalogue: Option<PathBuf>,
|
||||
/// The one new build dir to fold in (holds the just-updated libserver.so etc.).
|
||||
#[arg(long)]
|
||||
build: PathBuf,
|
||||
/// Must match the scope the model was distilled with (`clean` default).
|
||||
#[arg(long, value_enum, default_value = "clean")]
|
||||
class_scope: ClassScope,
|
||||
#[arg(long)]
|
||||
out: PathBuf,
|
||||
},
|
||||
/// Back-fill cross-build history for extrapolated (T3) names: for each {name, anchor} pair, resolve
|
||||
/// the anchor STRING uniquely in every corpus build (the same string-anchor locator as `anchor`), so
|
||||
/// a name that was a single-build guess gains a real timeline. Reports per-name history depth +
|
||||
/// consistency-since-first-appearance — the measure of how many T3 names graduate to first-class
|
||||
/// (a function anchored across hundreds of builds is high-confidence regardless of its T3 origin).
|
||||
Backfill {
|
||||
/// Raw build binaries — needed for the string-anchor half (locating a sig/self-named function in
|
||||
/// each historical build). Omit to run only the model-only offset half.
|
||||
#[arg(long)]
|
||||
corpus: Option<PathBuf>,
|
||||
/// Distilled corpus model — its `hops` back-fill an OFFSET function's vtable-slot timeline with
|
||||
/// NO binaries (the community-PR-of-a-vtable-method path). Omit to run only the string half.
|
||||
#[arg(long)]
|
||||
corpus_model: Option<PathBuf>,
|
||||
/// Server library to derive from; defaults to the active game's server lib.
|
||||
#[arg(long)]
|
||||
lib: Option<String>,
|
||||
/// JSON array of {name, tier?, anchor?, class?, slot?}: `anchor` (a distinctive string it
|
||||
/// references — its own name for self-named) drives the string half; `class`+`slot` drive the
|
||||
/// model-hops half.
|
||||
#[arg(long)]
|
||||
names: PathBuf,
|
||||
#[arg(long)]
|
||||
threads: Option<usize>,
|
||||
/// Write the per-name timeline report here.
|
||||
#[arg(long)]
|
||||
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
|
||||
/// (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
|
||||
/// `shift` (a toolchain/compiler change moved ~every function's codegen at once → re-derive, and the
|
||||
/// derive leans harder on the string-anchor/vtable recovery paths) plus the exact % of the new build's
|
||||
/// functions whose body isn't byte-identical to the
|
||||
/// previous build's. The thresholds are heuristic defaults — calibrate `--skip-below`/`--shift-above`
|
||||
/// against real adjacent-vs-toolchain-jump pairs.
|
||||
ClassifyChange {
|
||||
/// Previous build: a `.so` file directly, or a build dir to find `--lib` under.
|
||||
#[arg(long)]
|
||||
prev: PathBuf,
|
||||
/// New build: a `.so` file directly, or a build dir to find `--lib` under.
|
||||
#[arg(long)]
|
||||
new: PathBuf,
|
||||
/// Server library to derive from; defaults to the active game's server lib.
|
||||
#[arg(long)]
|
||||
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%.)
|
||||
#[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.
|
||||
#[arg(long, default_value_t = 0.20)]
|
||||
shift_above: f64,
|
||||
/// Emit a machine-readable JSON object instead of the human summary.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Stage-1 change-aware corpus filter: walk a game's builds chronologically, collapse runs of
|
||||
/// code-identical builds (bodies unchanged, only relocations moved) to ONE representative, label each
|
||||
/// surviving transition normal/shift, and segment the timeline into toolchain ERAS (cut at shifts).
|
||||
/// Writes a selection manifest (code-distinct kept builds + era/drift each). Lossless for the per-game
|
||||
/// facts; the distinct-build set `corpus-model` distills. Digests each build once.
|
||||
FilterCorpus {
|
||||
#[arg(long)]
|
||||
corpus: PathBuf,
|
||||
/// Server library to derive from; defaults to the active game's server lib.
|
||||
#[arg(long)]
|
||||
lib: Option<String>,
|
||||
/// changed-fraction below this collapses a build as code-identical. Default 0 = only exact
|
||||
/// 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).
|
||||
#[arg(long, default_value_t = 0.20)]
|
||||
shift_above: f64,
|
||||
#[arg(long)]
|
||||
threads: Option<usize>,
|
||||
/// Write the selection manifest here (JSON); prints to stdout if omitted.
|
||||
#[arg(long)]
|
||||
out: Option<PathBuf>,
|
||||
},
|
||||
}
|
||||
|
||||
/// A command's `--lib`, defaulting to the game's server library when unset.
|
||||
fn lib_or_default(prof: &profile::GameProfile, lib: Option<String>) -> String {
|
||||
lib.unwrap_or_else(|| prof.server_lib.to_string())
|
||||
}
|
||||
|
||||
/// Resolve the catalogue for the model commands (`corpus-model`/`fold-model`) from either a `--seed` bundle
|
||||
/// (release form) or a loose `--catalogue` file. The seed's catalogue section parses to the same functions as
|
||||
/// the loose `needed-functions.json`, so the distilled/folded model is identical either way. When a seed is
|
||||
/// given, its sections unpack under a `.seed` dir beside `out` (as `produce` does beside its out-dir).
|
||||
fn model_catalogue(
|
||||
prof: &profile::GameProfile,
|
||||
seed: Option<PathBuf>,
|
||||
catalogue: Option<PathBuf>,
|
||||
out: &std::path::Path,
|
||||
) -> Result<PathBuf> {
|
||||
match seed {
|
||||
Some(s) => {
|
||||
let work = out
|
||||
.parent()
|
||||
.unwrap_or_else(|| std::path::Path::new("."))
|
||||
.join(".seed");
|
||||
Ok(unpack_seed(prof, &s, &work)?.catalogue)
|
||||
}
|
||||
None => catalogue.context("pass --seed <bundle> or --catalogue <file>"),
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
// Thread the resolved profile as an explicit parameter rather than a process-wide global, so the
|
||||
// engine stays reusable per call.
|
||||
let profile = match cli.game {
|
||||
Game::Cs2 => &profile::CS2,
|
||||
Game::Dota2 => &profile::DOTA,
|
||||
};
|
||||
match cli.cmd {
|
||||
Cmd::IntegrationTest {
|
||||
game_dir,
|
||||
build,
|
||||
lib,
|
||||
wait,
|
||||
map,
|
||||
bots,
|
||||
gamedata,
|
||||
out,
|
||||
keep,
|
||||
fuzz_iterations,
|
||||
} => {
|
||||
let map = map.unwrap_or_else(|| profile.default_map.to_string());
|
||||
let lib = lib_or_default(profile, lib);
|
||||
integration_test_cmd(
|
||||
profile,
|
||||
&game_dir,
|
||||
build.as_deref(),
|
||||
&lib,
|
||||
wait,
|
||||
&map,
|
||||
bots,
|
||||
gamedata.as_deref(),
|
||||
out.as_deref(),
|
||||
keep,
|
||||
fuzz_iterations,
|
||||
)
|
||||
}
|
||||
Cmd::Produce {
|
||||
game_dir,
|
||||
build,
|
||||
lib,
|
||||
seed,
|
||||
catalogue,
|
||||
corpus,
|
||||
corpus_model,
|
||||
target,
|
||||
promotable,
|
||||
candidates,
|
||||
full_names,
|
||||
extra_offsets,
|
||||
extra_sigs,
|
||||
sig_cap,
|
||||
version,
|
||||
out_dir,
|
||||
class_scope,
|
||||
wait,
|
||||
map,
|
||||
bots,
|
||||
} => {
|
||||
// derive inputs come from a single --seed bundle (release form) or the loose flags (dev/verify).
|
||||
let inputs = match seed {
|
||||
Some(s) => unpack_seed(profile, &s, &out_dir.join(".seed"))?,
|
||||
None => SeedInputs {
|
||||
catalogue: catalogue.context("pass --seed <bundle> or --catalogue <file>")?,
|
||||
promotable,
|
||||
candidates,
|
||||
full_names,
|
||||
extra_offsets,
|
||||
extra_sigs,
|
||||
},
|
||||
};
|
||||
let map = map.unwrap_or_else(|| profile.default_map.to_string());
|
||||
let lib = lib_or_default(profile, lib);
|
||||
produce_cmd(ProduceArgs {
|
||||
prof: profile,
|
||||
game: game_dir.as_deref(),
|
||||
build: build.as_deref(),
|
||||
lib: &lib,
|
||||
catalogue: &inputs.catalogue,
|
||||
corpus: corpus.as_deref(),
|
||||
corpus_model: corpus_model.as_deref(),
|
||||
class_scope,
|
||||
target: &target,
|
||||
promotable: inputs.promotable.as_deref(),
|
||||
candidates: inputs.candidates.as_deref(),
|
||||
full_names: inputs.full_names.as_deref(),
|
||||
extra_offsets: inputs.extra_offsets.as_deref(),
|
||||
extra_sigs: inputs.extra_sigs.as_deref(),
|
||||
sig_cap,
|
||||
version: &version,
|
||||
out_dir: &out_dir,
|
||||
wait,
|
||||
map: &map,
|
||||
bots,
|
||||
})
|
||||
}
|
||||
Cmd::CorpusModel {
|
||||
seed,
|
||||
catalogue,
|
||||
corpus,
|
||||
class_scope,
|
||||
out,
|
||||
} => {
|
||||
let cat = model_catalogue(profile, seed, catalogue, &out)?;
|
||||
corpus_model_cmd(profile, &cat, &corpus, class_scope, &out)
|
||||
}
|
||||
Cmd::FoldModel {
|
||||
model,
|
||||
seed,
|
||||
catalogue,
|
||||
build,
|
||||
class_scope,
|
||||
out,
|
||||
} => {
|
||||
let cat = model_catalogue(profile, seed, catalogue, &out)?;
|
||||
fold_model_cmd(
|
||||
profile,
|
||||
load_model(&model)?,
|
||||
&cat,
|
||||
&build,
|
||||
class_scope,
|
||||
&out,
|
||||
)
|
||||
}
|
||||
Cmd::Backfill {
|
||||
corpus,
|
||||
corpus_model,
|
||||
lib,
|
||||
names,
|
||||
threads,
|
||||
out,
|
||||
} => {
|
||||
let lib = lib_or_default(profile, lib);
|
||||
backfill_cmd(
|
||||
profile,
|
||||
corpus.as_deref(),
|
||||
corpus_model.as_deref(),
|
||||
&lib,
|
||||
&names,
|
||||
threads,
|
||||
out.as_deref(),
|
||||
)
|
||||
}
|
||||
Cmd::ClassifyChange {
|
||||
prev,
|
||||
new,
|
||||
lib,
|
||||
skip_below,
|
||||
shift_above,
|
||||
json,
|
||||
} => {
|
||||
let lib = lib_or_default(profile, lib);
|
||||
classify_change_cmd(&prev, &new, &lib, skip_below, shift_above, json)
|
||||
}
|
||||
Cmd::FilterCorpus {
|
||||
corpus,
|
||||
lib,
|
||||
skip_below,
|
||||
shift_above,
|
||||
threads,
|
||||
out,
|
||||
} => {
|
||||
let lib = lib_or_default(profile, lib);
|
||||
filter_corpus_cmd(
|
||||
profile,
|
||||
&corpus,
|
||||
&lib,
|
||||
skip_below,
|
||||
shift_above,
|
||||
out.as_deref(),
|
||||
threads,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
48
src/par.rs
Normal file
48
src/par.rs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
//! Tiny data-parallel primitive shared across the crate — no work-stealing dependency, just scoped
|
||||
//! threads pulling work by an atomic index. Lives in the library (not the binary) so lib modules
|
||||
//! (`xref`, …) and the CI pipeline can parallelise directly, not only the CLI front-end.
|
||||
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
/// Run `f` over `items` across `nthreads` scoped threads, pulling work by atomic index
|
||||
/// (dynamic load-balancing without a work-stealing dep). Results returned in input order, so callers
|
||||
/// that merge them stay deterministic regardless of which thread finished which item.
|
||||
pub fn parallel_map<T, R, F>(items: &[T], nthreads: usize, f: F) -> Vec<R>
|
||||
where
|
||||
T: Sync,
|
||||
R: Send,
|
||||
F: Fn(&T) -> R + Sync,
|
||||
{
|
||||
let len = items.len();
|
||||
let nthreads = nthreads.clamp(1, len.max(1));
|
||||
let next = AtomicUsize::new(0);
|
||||
let out: Mutex<Vec<(usize, R)>> = Mutex::new(Vec::with_capacity(len));
|
||||
std::thread::scope(|s| {
|
||||
for _ in 0..nthreads {
|
||||
s.spawn(|| {
|
||||
let mut local = Vec::new();
|
||||
loop {
|
||||
let i = next.fetch_add(1, Ordering::Relaxed);
|
||||
if i >= len {
|
||||
break;
|
||||
}
|
||||
local.push((i, f(&items[i])));
|
||||
}
|
||||
out.lock().unwrap().extend(local);
|
||||
});
|
||||
}
|
||||
});
|
||||
let mut v = out.into_inner().unwrap();
|
||||
v.sort_by_key(|(i, _)| *i);
|
||||
v.into_iter().map(|(_, r)| r).collect()
|
||||
}
|
||||
|
||||
/// Requested thread count, or the machine's available parallelism.
|
||||
pub fn default_threads(threads: Option<usize>) -> usize {
|
||||
threads.unwrap_or_else(|| {
|
||||
std::thread::available_parallelism()
|
||||
.map(|n| n.get())
|
||||
.unwrap_or(4)
|
||||
})
|
||||
}
|
||||
3722
src/pipeline.rs
Normal file
3722
src/pipeline.rs
Normal file
File diff suppressed because it is too large
Load diff
1811
src/produce.rs
Normal file
1811
src/produce.rs
Normal file
File diff suppressed because it is too large
Load diff
367
src/profile.rs
Normal file
367
src/profile.rs
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
//! Per-game knobs — the only Source-2-*title*-specific constants, gathered in one place so a second
|
||||
//! game (Deadlock, Dota 2) is a data change, not a code hunt. CS2 and Dota 2 are registered today; the
|
||||
//! schema/RTTI/xref/oracle machinery around them is already game-generic.
|
||||
//!
|
||||
//! Everything a second game varies lives here: library names, the output game-key, the live-oracle
|
||||
//! launch spec, the player-pawn anchor, and the class/field/message-prefix literals the derivation and
|
||||
//! validation paths reference. (The SchemaSystem struct layout is deliberately NOT here — it tracks the
|
||||
//! engine BUILD ERA, not the game, so it lives as a per-binary `schema::SchemaLayout`.)
|
||||
|
||||
/// How to launch a vanilla server populated with alive units, for the live oracle. Structured (not a
|
||||
/// flat arg string) because a second Source-2 game selects its mode and fills its world completely
|
||||
/// differently (Dota 2 has no game_type/game_mode deathmatch, no bot_quota). The `args` builder
|
||||
/// reproduces the exact CS2 arg order, so a byte-identical launch is assertable.
|
||||
pub struct LaunchSpec {
|
||||
/// Cvars set before `-maxplayers`/`+map`, in order (CS2: the deathmatch `game_type 1` / `game_mode 2`).
|
||||
pub pre_map_cvars: &'static [(&'static str, &'static str)],
|
||||
/// Cvars set after `+map <map>`, in order. A value of `"{bots}"` is substituted with the runtime bot
|
||||
/// count (CS2's `+bot_quota <n>`); every other value is passed through verbatim.
|
||||
pub post_map_cvars: &'static [(&'static str, &'static str)],
|
||||
}
|
||||
|
||||
impl LaunchSpec {
|
||||
/// The vanilla dedicated-server args to spawn `bots` alive units on `map`, in the exact order the
|
||||
/// live launch requires.
|
||||
pub fn args(&self, map: &str, bots: u32) -> Vec<String> {
|
||||
let mut a: Vec<String> = vec!["-dedicated".into(), "-insecure".into()];
|
||||
for (k, v) in self.pre_map_cvars {
|
||||
a.push(format!("+{k}"));
|
||||
a.push((*v).into());
|
||||
}
|
||||
a.push("-maxplayers".into());
|
||||
a.push((bots + 4).to_string());
|
||||
a.push("+map".into());
|
||||
a.push(map.to_string());
|
||||
for (k, v) in self.post_map_cvars {
|
||||
a.push(format!("+{k}"));
|
||||
a.push(if *v == "{bots}" {
|
||||
bots.to_string()
|
||||
} else {
|
||||
(*v).into()
|
||||
});
|
||||
}
|
||||
a
|
||||
}
|
||||
}
|
||||
|
||||
/// The live-oracle player-pawn anchor: a pawn RTTI class, a liveness netvar, and the IsPlayerPawn vtable
|
||||
/// slot. Used to find an alive instance in a running server, prove a derived offset is really callable,
|
||||
/// and sweep this-only query methods. `Copy` so it round-trips out of a `const` profile cheaply.
|
||||
#[derive(Clone, Copy)]
|
||||
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)
|
||||
}
|
||||
|
||||
pub struct GameProfile {
|
||||
pub server_lib: &'static str, // the gameplay library (schema classes, most signatures)
|
||||
pub engine_lib: &'static str, // engine2 (entity system, networking)
|
||||
/// Every server-mapped Source-2 library the corpus model spans, ORDERED by resolution precedence
|
||||
/// (an earlier lib wins a class name present in more than one). `server_lib`/`engine_lib` are the first
|
||||
/// two and remain the "primary lib" for command defaults + the live-oracle readiness anchor. The
|
||||
/// client-render stack (libclient/panorama/rendersystemvulkan/cairo/…) is deliberately absent: the
|
||||
/// dedicated server never maps it (confirmed against `/proc/<pid>/maps` of a running server).
|
||||
pub libs: &'static [&'static str],
|
||||
/// How many vtable slots to read per class. A hard stop, not a hint: `rtti::read_slots` returns what it
|
||||
/// read with no truncation marker, so a class with more slots than this is INDISTINGUISHABLE from one
|
||||
/// that genuinely ends here — its tail silently vanishes, and `validate_offset` reports a legitimate
|
||||
/// slot past the cap as out-of-bounds. `extract_build_vtables` WARNS when a class lands exactly on the
|
||||
/// cap, which is how a game that needs a bigger one is discovered.
|
||||
///
|
||||
/// At 2048: CS2's deepest class is ~464 and Dota's ~541 (the `CDOTA_BaseNPC_*` / `CDOTA_Unit_Hero_*`
|
||||
/// family). 2048 is deliberately far above need: `read_slots` stops at the first slot that isn't
|
||||
/// executable code, so a normal class costs nothing extra and only genuinely deep vtables scan further.
|
||||
///
|
||||
/// **Raising this invalidates that game's model.** Slot counts and per-slot fingerprints are recorded
|
||||
/// under the cap in force at distill time; a derive that reads deeper vtables than the model was built
|
||||
/// from is comparing different objects. A raise is a re-distill, not a config tweak — change it and the
|
||||
/// model together.
|
||||
pub max_vtable_slots: usize,
|
||||
/// 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`.
|
||||
pub token: &'static str,
|
||||
/// Dedicated-server launcher binary under `bin/linuxsteamrt64/` (CS2: `cs2`).
|
||||
pub executable: &'static str,
|
||||
/// Default map for the live-oracle server.
|
||||
pub default_map: &'static str,
|
||||
/// This game's user-message class prefix, dropped as wire/serializer dead weight (CS2: `CCSUsrMsg`).
|
||||
pub usermsg_prefix: &'static str,
|
||||
/// Dead-weight / name-classification vocabulary — the retunable taxonomy a fork edits per game.
|
||||
/// `foreign_namespaces`: RTTI namespaces that are never gameplay (the C++ runtime, Steam GC SDK, Valve
|
||||
/// container templates, the V8 vscript backend). `proto_prefixes`: protobuf message-class name prefixes
|
||||
/// (wire/GC protocol). Most values are Source-2-universal, but they ride the profile so a fork retunes
|
||||
/// one const block instead of hunting a second file.
|
||||
pub foreign_namespaces: &'static [&'static str],
|
||||
pub proto_prefixes: &'static [&'static str],
|
||||
/// Protobuf serializer method names: a lone HARD one decisively marks its class generated wire plumbing;
|
||||
/// SOFT ones can be legit game methods, so they only count toward the ≥3-method protobuf-class cluster.
|
||||
pub hard_serializer: &'static [&'static str],
|
||||
pub soft_serializer: &'static [&'static str],
|
||||
/// Method-name prefixes for a this-only blind-callable boolean query — the live call-smoke-test gate.
|
||||
pub query_prefixes: &'static [&'static str],
|
||||
/// Live-oracle "famous field" spotlight: per class, the netvars whose live offsets `verify-live` prints
|
||||
/// field-by-field (the ones mods actually read). CS2 gameplay fields on the generic `CBaseEntity`.
|
||||
pub spotlight_fields: &'static [(&'static str, &'static [&'static str])],
|
||||
/// Human-readable game name for the shipped gamedata banner.
|
||||
pub display_name: &'static str,
|
||||
/// How the live oracle spawns alive units.
|
||||
pub launch: LaunchSpec,
|
||||
/// RTTI class of the always-present gamerules proxy. A pawn-less game (Dota) uses a live instance of it
|
||||
/// as the live-oracle readiness signal (a live one means the map loaded and libserver is ready) in
|
||||
/// place of an alive pawn; set for every game though pawn games use the alive-pawn poll.
|
||||
pub ready_class: &'static str,
|
||||
/// The live-oracle player-pawn anchor, or `None` for a pawn-less game (Dota 2 units are
|
||||
/// CDOTA_BaseNPC/heroes, not a spawned CCSPlayerPawn). `Some` runs the alive-pawn poll + IsPlayerPawn
|
||||
/// call test + callable-method sweep; `None` skips them — the pawn-less live flow (an alternate
|
||||
/// entity anchor, or a bot-match-with-no-players readiness signal) is a placeholder TBD at bring-up.
|
||||
pub pawn_anchor: Option<PawnAnchor>,
|
||||
}
|
||||
|
||||
/// Counter-Strike 2.
|
||||
pub const CS2: GameProfile = GameProfile {
|
||||
server_lib: "libserver.so",
|
||||
engine_lib: "libengine2.so",
|
||||
// Every Valve Source-2 library the dedicated server maps (confirmed from /proc/<pid>/maps), ordered
|
||||
// by resolution precedence: gameplay (server) then engine2 win shared-infra class-name collisions,
|
||||
// then the systems by rough dependency depth. Excludes the client-render stack (never server-mapped)
|
||||
// and the V8/Steam vendored runtimes (foreign code, filtered by taxonomy).
|
||||
libs: &[
|
||||
"libserver.so",
|
||||
"libengine2.so",
|
||||
"libtier0.so",
|
||||
"libnetworksystem.so",
|
||||
"libschemasystem.so",
|
||||
"libresourcesystem.so",
|
||||
"libscenesystem.so",
|
||||
"libsoundsystem.so",
|
||||
"libanimationsystem.so",
|
||||
"libvphysics2.so",
|
||||
"libmeshsystem.so",
|
||||
"libparticles.so",
|
||||
"libworldrenderer.so",
|
||||
"libmaterialsystem2.so",
|
||||
"libscenefilecache.so",
|
||||
"libfilesystem_stdio.so",
|
||||
"liblocalize.so",
|
||||
"libhost.so",
|
||||
"libmatchmaking.so",
|
||||
"libpulse_system.so",
|
||||
"librendersystemempty.so",
|
||||
"libvscript.so",
|
||||
],
|
||||
max_vtable_slots: 2048,
|
||||
game_key: "csgo",
|
||||
token: "cs2",
|
||||
executable: "cs2",
|
||||
default_map: "de_dust2",
|
||||
usermsg_prefix: "CCSUsrMsg",
|
||||
foreign_namespaces: &[
|
||||
"google::protobuf",
|
||||
"std::",
|
||||
"__gnu_cxx",
|
||||
"__cxxabiv1",
|
||||
"GCSDK::",
|
||||
"CUtl",
|
||||
"v8::",
|
||||
],
|
||||
proto_prefixes: &[
|
||||
"CMsg", "CSVCMsg", "CNETMsg", "CCLCMsg", "CMsgGC", "CDataGC", "CGC", "CSO", "PB_",
|
||||
],
|
||||
hard_serializer: &[
|
||||
"GetCachedSize",
|
||||
"ByteSizeLong",
|
||||
"IsInitialized",
|
||||
"GetMetadata",
|
||||
"MergePartialFromCodedStream",
|
||||
"SerializeWithCachedSizes",
|
||||
"InternalSerialize",
|
||||
"GetClassData",
|
||||
"MergeImpl",
|
||||
"_InternalParse",
|
||||
],
|
||||
soft_serializer: &[
|
||||
"New",
|
||||
"Clear",
|
||||
"CopyFrom",
|
||||
"MergeFrom",
|
||||
"SharedCtor",
|
||||
"SharedDtor",
|
||||
],
|
||||
query_prefixes: &["Is", "Has", "Can", "Should", "Are", "Will"],
|
||||
spotlight_fields: &[(
|
||||
"CBaseEntity",
|
||||
&["m_iHealth", "m_iTeamNum", "m_hOwnerEntity"],
|
||||
)],
|
||||
display_name: "CS2",
|
||||
launch: LaunchSpec {
|
||||
pre_map_cvars: &[("game_type", "1"), ("game_mode", "2")],
|
||||
post_map_cvars: &[
|
||||
("sv_hibernate_when_empty", "0"),
|
||||
("bot_join_after_player", "0"),
|
||||
("bot_quota", "{bots}"),
|
||||
("bot_quota_mode", "fill"),
|
||||
("bot_difficulty", "2"),
|
||||
("mp_warmuptime", "0"),
|
||||
],
|
||||
},
|
||||
ready_class: "CCSGameRulesProxy",
|
||||
pawn_anchor: Some(PawnAnchor {
|
||||
pawn_class: "CCSPlayerPawn",
|
||||
health_field: "m_iHealth",
|
||||
is_player_pawn_slot: 168,
|
||||
}),
|
||||
};
|
||||
|
||||
/// Dota 2. Dota has no deathmatch `game_type`/`game_mode` or `bot_quota`, so the live oracle needs a
|
||||
/// different keep-alive combination than CS2; the `launch` cvars are the real bot-match cvars found in
|
||||
/// `libserver.so` (see `launch`).
|
||||
pub const DOTA: GameProfile = GameProfile {
|
||||
server_lib: "libserver.so", // generic Source-2 (same filename as CS2)
|
||||
engine_lib: "libengine2.so",
|
||||
// The full Source-2 server lib set (same names as CS2 — shared engine; Dota's build supplies its own
|
||||
// versions). Superset-safe: `load_build_images` skips any lib absent from Dota's build. Confirm against a
|
||||
// running Dota server's /proc/maps if a Dota-specific server lib ever appears outside this set.
|
||||
libs: &[
|
||||
"libserver.so",
|
||||
"libengine2.so",
|
||||
"libtier0.so",
|
||||
"libnetworksystem.so",
|
||||
"libschemasystem.so",
|
||||
"libresourcesystem.so",
|
||||
"libscenesystem.so",
|
||||
"libsoundsystem.so",
|
||||
"libanimationsystem.so",
|
||||
"libvphysics2.so",
|
||||
"libmeshsystem.so",
|
||||
"libparticles.so",
|
||||
"libworldrenderer.so",
|
||||
"libmaterialsystem2.so",
|
||||
"libscenefilecache.so",
|
||||
"libfilesystem_stdio.so",
|
||||
"liblocalize.so",
|
||||
"libhost.so",
|
||||
"libmatchmaking.so",
|
||||
"libpulse_system.so",
|
||||
"librendersystemempty.so",
|
||||
"libvscript.so",
|
||||
],
|
||||
max_vtable_slots: 2048,
|
||||
game_key: "dota",
|
||||
token: "dota2",
|
||||
executable: "dota2", // bin/linuxsteamrt64/dota2
|
||||
default_map: "dota",
|
||||
usermsg_prefix: "CDOTAUserMsg",
|
||||
// Same Source-2-universal dead-weight vocabulary as CS2; only `usermsg_prefix` above is genuinely
|
||||
// per-game. A Dota-specific tune would edit here.
|
||||
foreign_namespaces: &[
|
||||
"google::protobuf",
|
||||
"std::",
|
||||
"__gnu_cxx",
|
||||
"__cxxabiv1",
|
||||
"GCSDK::",
|
||||
"CUtl",
|
||||
"v8::",
|
||||
],
|
||||
proto_prefixes: &[
|
||||
"CMsg", "CSVCMsg", "CNETMsg", "CCLCMsg", "CMsgGC", "CDataGC", "CGC", "CSO", "PB_",
|
||||
],
|
||||
hard_serializer: &[
|
||||
"GetCachedSize",
|
||||
"ByteSizeLong",
|
||||
"IsInitialized",
|
||||
"GetMetadata",
|
||||
"MergePartialFromCodedStream",
|
||||
"SerializeWithCachedSizes",
|
||||
"InternalSerialize",
|
||||
"GetClassData",
|
||||
"MergeImpl",
|
||||
"_InternalParse",
|
||||
],
|
||||
soft_serializer: &[
|
||||
"New",
|
||||
"Clear",
|
||||
"CopyFrom",
|
||||
"MergeFrom",
|
||||
"SharedCtor",
|
||||
"SharedDtor",
|
||||
],
|
||||
query_prefixes: &["Is", "Has", "Can", "Should", "Are", "Will"],
|
||||
spotlight_fields: &[(
|
||||
"CBaseEntity",
|
||||
&["m_iHealth", "m_iTeamNum", "m_hOwnerEntity"],
|
||||
)],
|
||||
display_name: "Dota 2",
|
||||
// Dota 2 has no deathmatch `game_type`/`game_mode` or `bot_quota`; a headless AI/bot match uses these
|
||||
// cvars (all present in libserver.so). `sv_hibernate_when_empty 0` is REQUIRED — without it an empty
|
||||
// dedicated server hibernates and quits immediately (exit 0). The pawn-only stages gate on `pawn_anchor`
|
||||
// being `Some`, so Dota (whose `pawn_anchor` is `None`) validates through the schema + sig oracles
|
||||
// without a pawn, polling `ready_class` (a live CDOTAGamerulesProxy = map loaded) in place of an alive
|
||||
// pawn.
|
||||
launch: LaunchSpec {
|
||||
// Keep an empty headless Dota server ALIVE long enough to attach + validate: sv_cheats enables dev
|
||||
// commands, hibernate-off stops it quitting when empty, and the huge auto-surrender timeout defeats
|
||||
// the empty-match abandon that otherwise closes it after a few minutes. Bots are NOT populated here
|
||||
// (that needs a post-map-load stdin command and is only for the entity oracle); sig validation needs
|
||||
// only libserver loaded + a map.
|
||||
pre_map_cvars: &[("sv_cheats", "1"), ("dota_force_gamemode", "1")],
|
||||
post_map_cvars: &[
|
||||
("sv_hibernate_when_empty", "0"),
|
||||
("dota_auto_surrender_all_disconnected_timeout", "999999"),
|
||||
("dota_local_bot_match_difficulty", "1"),
|
||||
],
|
||||
},
|
||||
ready_class: "CDOTAGamerulesProxy",
|
||||
// Dota 2 units are `CDOTA_BaseNPC_Hero` NPCs, not a spawned `CCSPlayerPawn` — no player-pawn anchor. The
|
||||
// pawn-based oracle (alive-pawn poll + IsPlayerPawn call + this-only method sweep) is skipped; the SCHEMA
|
||||
// oracle (attach + read SchemaSystem, no pawn) is the portable core. A hero-NPC anchor is the pawn-less
|
||||
// extension, TBD at bring-up.
|
||||
pawn_anchor: None,
|
||||
};
|
||||
|
||||
// (There is deliberately NO process-wide "active profile" global. `main` resolves `--game` to a
|
||||
// `&'static GameProfile` and threads it explicitly through every engine entry point, so the library is
|
||||
// reusable per-call — CS2 and Dota can be derived in the same process — and no code path can silently
|
||||
// run one game's assumptions on another.)
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cs2_launch_args_are_byte_identical_to_the_old_hand_synced_vec() {
|
||||
// The exact arg vec the live launch requires for map="de_dust2", bots=9 — pins the LaunchSpec
|
||||
// builder to a byte-identical launch.
|
||||
let expected: Vec<String> = [
|
||||
"-dedicated",
|
||||
"-insecure",
|
||||
"+game_type",
|
||||
"1",
|
||||
"+game_mode",
|
||||
"2",
|
||||
"-maxplayers",
|
||||
"13",
|
||||
"+map",
|
||||
"de_dust2",
|
||||
"+sv_hibernate_when_empty",
|
||||
"0",
|
||||
"+bot_join_after_player",
|
||||
"0",
|
||||
"+bot_quota",
|
||||
"9",
|
||||
"+bot_quota_mode",
|
||||
"fill",
|
||||
"+bot_difficulty",
|
||||
"2",
|
||||
"+mp_warmuptime",
|
||||
"0",
|
||||
]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
assert_eq!(CS2.launch.args("de_dust2", 9), expected);
|
||||
}
|
||||
}
|
||||
276
src/rtti.rs
Normal file
276
src/rtti.rs
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
//! Offline Itanium C++ RTTI: locate a class's vtable in an ELF `.so` and read its slot array.
|
||||
//!
|
||||
//! Chain (Itanium ABI, LP64): the class name is stored length-prefixed+mangled (e.g.
|
||||
//! "11CBaseEntity") as a `_ZTS` string in `.rodata`; the `_ZTI` typeinfo points to that string
|
||||
//! at +8; the `_ZTV` vtable points to the typeinfo at +8, with `offset-to-top` at +0, so virtual
|
||||
//! slots start at vtable+16. Those slot pointers live in `.data.rel.ro` and are supplied by
|
||||
//! relocations, which `CodeImage::read_ptr` already resolves.
|
||||
//!
|
||||
//! This is the ELF/Itanium half; a Windows fork would add an MSVC-RTTI sibling behind the same
|
||||
//! `find_vtable` shape (COL at vftable-8, TypeDescriptor `.?AV<name>@@`).
|
||||
|
||||
use crate::elf::{CodeImage, KindTag};
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub struct VTable {
|
||||
pub slot0: u64, // vaddr of virtual slot index 0
|
||||
pub slots: Vec<u64>, // function vaddrs; gamedata offset of a method == its index here
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// A direct base class of a type, from its Itanium typeinfo.
|
||||
pub struct BaseClass {
|
||||
pub name: String, // demangled base class name
|
||||
pub offset: i64, // this-pointer adjustment to the base subobject (0 for the primary base)
|
||||
pub virtual_base: bool, // true if inherited virtually
|
||||
}
|
||||
|
||||
/// The three Itanium `type_info` "kind" vtables (their in-object `+16` slot0 pointers). libc++abi is
|
||||
/// statically bundled in CS2 libraries, so these resolve as WEAK symbols and let us classify each
|
||||
/// typeinfo *exactly* — no heuristic guess of `__class` vs `__si` vs `__vmi`.
|
||||
struct RttiKinds {
|
||||
class: u64, // __class_type_info — no bases
|
||||
si: u64, // __si_class_type_info — single public base at offset 0
|
||||
vmi: u64, // __vmi_class_type_info — multiple / virtual / non-public bases
|
||||
}
|
||||
|
||||
impl RttiKinds {
|
||||
fn detect(img: &CodeImage) -> Self {
|
||||
let k = |n: &str| img.symbol_addr(n).map_or(0, |a| a.wrapping_add(16));
|
||||
Self {
|
||||
class: k("_ZTVN10__cxxabiv117__class_type_infoE"),
|
||||
si: k("_ZTVN10__cxxabiv120__si_class_type_infoE"),
|
||||
vmi: k("_ZTVN10__cxxabiv121__vmi_class_type_infoE"),
|
||||
}
|
||||
}
|
||||
/// Is `p` (a typeinfo's `+0` field) one of the three kind vtables? When the kind symbols are
|
||||
/// stripped (all zero) we can't tell, so accept any pointer the caller already range-checked.
|
||||
fn is_kind(&self, p: u64) -> bool {
|
||||
if self.class == 0 && self.si == 0 && self.vmi == 0 {
|
||||
return true;
|
||||
}
|
||||
p == self.class || p == self.si || p == self.vmi
|
||||
}
|
||||
}
|
||||
|
||||
/// Itanium length-prefixed name for a flat class, e.g. `CBaseEntity` -> `11CBaseEntity`.
|
||||
/// (Namespaced/templated names need full mangling; our targets are flat class names.)
|
||||
fn mangle(class: &str) -> String {
|
||||
format!("{}{}", class.len(), class)
|
||||
}
|
||||
|
||||
/// Find the class's primary (complete-object) vtable and read its function-pointer slots.
|
||||
pub fn find_vtable(img: &CodeImage, class: &str, max_slots: usize) -> Option<VTable> {
|
||||
let mut candidates: Vec<u64> = Vec::new();
|
||||
|
||||
// Fast path: an exported `_ZTV` symbol (uncommon for gameplay classes, but cheap).
|
||||
if let Some(ztv) = img.symbol_addr(&format!("_ZTV{}", mangle(class))) {
|
||||
candidates.push(ztv.wrapping_add(16));
|
||||
}
|
||||
|
||||
// General path: name string -> typeinfo (points to name at +8) -> vtable (points to TI at +8).
|
||||
let mut needle = mangle(class).into_bytes();
|
||||
needle.push(0);
|
||||
for name_str in img.find_bytes(&needle) {
|
||||
for &ti_name_slot in img.ptrs_to(name_str) {
|
||||
if ti_name_slot < 8 {
|
||||
continue;
|
||||
}
|
||||
let typeinfo = ti_name_slot - 8;
|
||||
for &vt_ti_slot in img.ptrs_to(typeinfo) {
|
||||
candidates.push(vt_ti_slot.wrapping_add(8));
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates.sort_unstable();
|
||||
candidates.dedup();
|
||||
|
||||
for slot0 in candidates {
|
||||
// primary vtable has offset-to-top == 0 at slot0-16; filters typeinfo base-class lists
|
||||
if img.read_ptr(slot0.wrapping_sub(16)) != Some(0) {
|
||||
continue;
|
||||
}
|
||||
let slots = read_slots(img, slot0, max_slots);
|
||||
// Higher floor than `enumerate_vtables` (which admits `>= 2`): a 2-slot stub is too thin to trust
|
||||
// as the TARGET's real vtable when matching by name. A class whose primary vtable has exactly 2
|
||||
// code slots is still catalogued in the model but not re-located here, so its offsets flag
|
||||
// unresolved — a missed derivation for a rare class, never a wrong value.
|
||||
if slots.len() >= 3 {
|
||||
return Some(VTable { slot0, slots });
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Consecutive slot pointers that land in executable code; stops at the first that doesn't.
|
||||
///
|
||||
/// Returning exactly `max` slots is AMBIGUOUS — the vtable may genuinely end there, or may continue past
|
||||
/// the cap with the tail silently dropped. Callers that care (the ones recording slot counts into the
|
||||
/// model) should compare `len() == max` and warn; see `GameProfile::max_vtable_slots`.
|
||||
fn read_slots(img: &CodeImage, slot0: u64, max: usize) -> Vec<u64> {
|
||||
let mut out = Vec::new();
|
||||
for i in 0..max {
|
||||
match img.read_ptr(slot0.wrapping_add((i as u64).wrapping_mul(8))) {
|
||||
Some(v) if img.is_code(v) => out.push(v),
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Demangle an Itanium *type* name (the bare `_ZTS` payload, e.g. "11CBaseEntity") to a readable
|
||||
/// class name. cpp_demangle wants a whole symbol, so we re-attach the `_ZTS` prefix and strip the
|
||||
/// "typeinfo name for " decoration it produces. Falls back to the mangled form.
|
||||
fn demangle_type(mangled: &str) -> String {
|
||||
let sym = format!("_ZTS{mangled}");
|
||||
cpp_demangle::Symbol::new(sym.as_bytes())
|
||||
.ok()
|
||||
.and_then(|s| s.demangle().ok())
|
||||
.map(|d| {
|
||||
d.strip_prefix("typeinfo name for ")
|
||||
.unwrap_or(&d)
|
||||
.to_string()
|
||||
})
|
||||
.unwrap_or_else(|| mangled.to_string())
|
||||
}
|
||||
|
||||
/// If `ti` addresses a valid Itanium typeinfo, return its `(mangled, 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)> {
|
||||
// +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).
|
||||
if img.kind_at(ti).is_none() {
|
||||
let kind = img.read_ptr(ti)?;
|
||||
if kind == 0 || !img.contains(kind) || !kinds.is_kind(kind) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let name_ptr = img.read_ptr(ti.wrapping_add(8))?;
|
||||
let mangled = img.read_c_string(name_ptr)?;
|
||||
// Itanium type names start with a length digit (flat class) or a mangling sigil.
|
||||
let c0 = *mangled.as_bytes().first()?;
|
||||
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)))
|
||||
}
|
||||
|
||||
/// Direct base classes of the typeinfo at `ti`, dispatched on its exact Itanium kind.
|
||||
fn typeinfo_bases(img: &CodeImage, ti: u64, kinds: &RttiKinds) -> Vec<BaseClass> {
|
||||
// Classify the kind: prefer the symbol-name tag (dynamically-linked runtime), else compare the resolved
|
||||
// +0 pointer to the in-image kind vtables (statically-linked). Without the tag, an old build can't tell
|
||||
// __si from __vmi at all, and the base graph would silently come back empty.
|
||||
let tag = img.kind_at(ti).or_else(|| {
|
||||
let kind = img.read_ptr(ti).unwrap_or(0);
|
||||
if kind == 0 {
|
||||
None
|
||||
} else if kind == kinds.si {
|
||||
Some(KindTag::Si)
|
||||
} else if kind == kinds.vmi {
|
||||
Some(KindTag::Vmi)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
match tag {
|
||||
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)
|
||||
{
|
||||
return vec![BaseClass {
|
||||
name,
|
||||
offset: 0,
|
||||
virtual_base: false,
|
||||
}];
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
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 {
|
||||
return Vec::new();
|
||||
};
|
||||
if count == 0 || count > 128 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut bases = Vec::new();
|
||||
for i in 0..count as u64 {
|
||||
let e = ti.wrapping_add(24).wrapping_add(i.wrapping_mul(16));
|
||||
let Some(bp) = img.read_ptr(e) else {
|
||||
break;
|
||||
};
|
||||
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,
|
||||
offset: of >> 8, // Itanium: high bits = this-pointer adjustment
|
||||
virtual_base: of & 0x1 != 0, // low byte: 0x1 = virtual, 0x2 = public
|
||||
});
|
||||
}
|
||||
}
|
||||
bases
|
||||
}
|
||||
_ => Vec::new(), // __class_type_info (no bases) or a kind we can't classify
|
||||
}
|
||||
}
|
||||
|
||||
/// Enumerate EVERY class vtable in the image via Itanium RTTI — the whole-binary class inventory.
|
||||
///
|
||||
/// Reloc-driven (not a raw byte sweep): each vtable's typeinfo field at `vtable-8` is a relocation,
|
||||
/// so we walk the reloc map, keep slots that point at a valid typeinfo, and recover the vtable just
|
||||
/// above. Every pointer is read through the `.rela.dyn`-resolved `read_ptr`, so `.data.rel.ro` slots
|
||||
/// (zero on disk) come back as their true as-loaded values.
|
||||
pub fn enumerate_vtables(img: &CodeImage, max_slots: usize) -> Vec<ClassVtable> {
|
||||
let kinds = RttiKinds::detect(img);
|
||||
let mut out = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
for (slot, val) in img.reloc_slots() {
|
||||
if slot < 8 {
|
||||
continue;
|
||||
}
|
||||
let Some((mangled, name)) = typeinfo_name(img, val, &kinds) else {
|
||||
continue;
|
||||
};
|
||||
let vtable_va = slot.wrapping_add(8);
|
||||
if !seen.insert(vtable_va) {
|
||||
continue;
|
||||
}
|
||||
// offset-to-top sits at vtable-16 (just below the typeinfo field): a plain, non-relocated,
|
||||
// pointer-aligned int, 0 for a primary table and a small negative for sub-object tables.
|
||||
let Some(ott) = img.read_i64(slot.wrapping_sub(8)) else {
|
||||
continue;
|
||||
};
|
||||
if !(-(1 << 24)..=0).contains(&ott) || ott % 8 != 0 {
|
||||
continue;
|
||||
}
|
||||
let slots = read_slots(img, vtable_va, max_slots);
|
||||
if slots.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let bases = typeinfo_bases(img, val, &kinds);
|
||||
out.push(ClassVtable {
|
||||
mangled,
|
||||
name,
|
||||
vtable_va,
|
||||
offset_to_top: ott,
|
||||
typeinfo: val,
|
||||
slots,
|
||||
bases,
|
||||
});
|
||||
}
|
||||
out.sort_by_key(|c| c.vtable_va);
|
||||
out
|
||||
}
|
||||
353
src/schema.rs
Normal file
353
src/schema.rs
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
//! Offline Source-2 **SchemaSystem** reader: recover class instance sizes, field offsets and base
|
||||
//! classes straight from Valve's own reflection tables in a stripped `.so` — making the field-offset
|
||||
//! half of gamedata *deterministic* (no fingerprint carry-forward, no "verify this guess").
|
||||
//!
|
||||
//! Source 2 emits, as static data, a `SchemaClassInfoData_t` per registered class (its name, size,
|
||||
//! field array, base array) plus a `SchemaClassFieldData_t` per field (name, type, offset). The
|
||||
//! struct layouts here are the authoritative LP64 layouts from hl2sdk-cs2
|
||||
//! `public/schemasystem/schematypes.h`.
|
||||
//!
|
||||
//! Root discovery is reloc-driven, mirroring `rtti::enumerate_vtables`: every class name pointer is
|
||||
//! a relocation, so we treat each reloc slot as a candidate `m_pszName` field, read the struct just
|
||||
//! below it, and validate (sane size + field count, a real fields pointer, and a first field named
|
||||
//! `m_…` — Source 2's universal member-prefix, which alone rejects essentially all false positives).
|
||||
//! Every pointer is read through `CodeImage::read_ptr`, so `.data.rel.ro` slots (zero on disk) come
|
||||
//! back as their true as-loaded values.
|
||||
|
||||
use crate::elf::CodeImage;
|
||||
use crate::profile::GameProfile;
|
||||
use crate::{live, model};
|
||||
use anyhow::Result;
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::path::Path;
|
||||
|
||||
/// Byte offsets of the SchemaSystem reflection structs (SchemaClassInfoData_t / SchemaClassFieldData_t /
|
||||
/// SchemaBaseClassInfoData_t, LP64 — hl2sdk-cs2 public/schemasystem/schematypes.h). Grouped into one
|
||||
/// swappable value because this layout tracks the engine BUILD ERA (Valve reshapes these structs across
|
||||
/// engine updates), NOT the game. That makes it orthogonal to `GameProfile`: a future per-era detector
|
||||
/// ships several `SchemaLayout`s and picks one per binary. Today there is exactly one — `CURRENT_LAYOUT`,
|
||||
/// the source of truth the rest of this module and the live oracle read through.
|
||||
pub struct SchemaLayout {
|
||||
pub ci_binding: u64, // CSchemaClassInfo* m_pSchemaBinding (0 on disk, populated at runtime)
|
||||
pub ci_name: u64, // const char* m_pszName
|
||||
pub ci_size: u64, // int m_nSize
|
||||
pub ci_field_count: u64, // uint16 m_nFieldCount
|
||||
pub ci_base_count: u64, // uint8 m_nBaseClassCount
|
||||
pub ci_fields: u64, // SchemaClassFieldData_t* m_pFields
|
||||
pub ci_bases: u64, // SchemaBaseClassInfoData_t* m_pBaseClasses
|
||||
pub f_name: u64, // SchemaClassFieldData_t::m_pszName
|
||||
pub f_offset: u64, // SchemaClassFieldData_t::m_nSingleInheritanceOffset
|
||||
pub f_stride: u64, // sizeof(SchemaClassFieldData_t)
|
||||
pub b_offset: u64, // SchemaBaseClassInfoData_t::m_nOffset
|
||||
pub b_class: u64, // SchemaBaseClassInfoData_t::m_pClass
|
||||
pub b_stride: u64, // sizeof(SchemaBaseClassInfoData_t)
|
||||
// ---- CSchemaType: a SECOND runtime struct, reachable only from a live process ----
|
||||
// `SchemaClassFieldData_t::m_pType` points at it, and the typed-netvars walk reads the type's name and
|
||||
// category through it. It belongs here for the same reason the rest does: this is engine-ERA layout
|
||||
// Valve reshapes across builds — kept beside the offline offsets so a reshape can't pass every offline
|
||||
// check and still ship a netvars file full of empty types.
|
||||
pub f_type: u64, // SchemaClassFieldData_t::m_pType
|
||||
pub ty_name: u64, // CSchemaType::m_pszName
|
||||
pub ty_category: u64, // CSchemaType::m_eTypeCategory (low byte)
|
||||
}
|
||||
|
||||
/// The one layout in service — current CS2/Source-2 engine era.
|
||||
pub const CURRENT_LAYOUT: SchemaLayout = SchemaLayout {
|
||||
ci_binding: 0,
|
||||
ci_name: 8,
|
||||
ci_size: 32,
|
||||
ci_field_count: 36,
|
||||
ci_base_count: 41,
|
||||
ci_fields: 48,
|
||||
ci_bases: 56,
|
||||
f_name: 0,
|
||||
f_offset: 16,
|
||||
f_stride: 32,
|
||||
b_offset: 0,
|
||||
b_class: 8,
|
||||
b_stride: 16,
|
||||
f_type: 8,
|
||||
ty_name: 8,
|
||||
ty_category: 24,
|
||||
};
|
||||
|
||||
// The offsets projected as module consts — the stable interface the parser (below) and the runtime
|
||||
// oracle (`produce::verify_live_cmd` via `schema::CI_*` / `F_*`) read. Sourced from `CURRENT_LAYOUT` so it stays the
|
||||
// single source of truth; a per-era swap changes only the const above.
|
||||
pub const CI_BINDING: u64 = CURRENT_LAYOUT.ci_binding;
|
||||
pub const CI_NAME: u64 = CURRENT_LAYOUT.ci_name;
|
||||
pub const CI_SIZE: u64 = CURRENT_LAYOUT.ci_size;
|
||||
pub const CI_FIELD_COUNT: u64 = CURRENT_LAYOUT.ci_field_count;
|
||||
const CI_BASE_COUNT: u64 = CURRENT_LAYOUT.ci_base_count;
|
||||
pub const CI_FIELDS: u64 = CURRENT_LAYOUT.ci_fields;
|
||||
const CI_BASES: u64 = CURRENT_LAYOUT.ci_bases;
|
||||
const F_NAME: u64 = CURRENT_LAYOUT.f_name;
|
||||
pub const F_OFFSET: u64 = CURRENT_LAYOUT.f_offset;
|
||||
pub const F_STRIDE: u64 = CURRENT_LAYOUT.f_stride;
|
||||
pub const F_TYPE: u64 = CURRENT_LAYOUT.f_type;
|
||||
pub const TY_NAME: u64 = CURRENT_LAYOUT.ty_name;
|
||||
pub const TY_CATEGORY: u64 = CURRENT_LAYOUT.ty_category;
|
||||
const B_OFFSET: u64 = CURRENT_LAYOUT.b_offset;
|
||||
const B_CLASS: u64 = CURRENT_LAYOUT.b_class;
|
||||
const B_STRIDE: u64 = CURRENT_LAYOUT.b_stride;
|
||||
|
||||
pub struct SchemaField {
|
||||
pub name: String,
|
||||
pub offset: i32,
|
||||
}
|
||||
|
||||
pub struct SchemaBase {
|
||||
pub name: String,
|
||||
pub offset: u32,
|
||||
}
|
||||
|
||||
/// One registered Source-2 class recovered from the schema tables.
|
||||
pub struct SchemaClass {
|
||||
pub name: String,
|
||||
pub class_info: u64, // vaddr of the SchemaClassInfoData_t
|
||||
pub name_ptr: u64, // reloc-resolved vaddr of the name string (for live cross-check)
|
||||
pub size: i32, // instance size in bytes
|
||||
pub bases: Vec<SchemaBase>,
|
||||
pub fields: Vec<SchemaField>,
|
||||
}
|
||||
|
||||
impl SchemaClass {
|
||||
/// The primary (offset-0) base class name, if any — for cross-checking against the RTTI chain.
|
||||
pub fn primary_base(&self) -> Option<&str> {
|
||||
self.bases
|
||||
.iter()
|
||||
.find(|b| b.offset == 0)
|
||||
.map(|b| b.name.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// A schema type name: an identifier plus the template/namespace punctuation Source 2 uses.
|
||||
fn is_type_name(s: &str) -> bool {
|
||||
let b = s.as_bytes();
|
||||
if b.is_empty() || b.len() >= 256 {
|
||||
return false;
|
||||
}
|
||||
if !(b[0].is_ascii_alphabetic() || b[0] == b'_') {
|
||||
return false;
|
||||
}
|
||||
s.chars().all(|c| {
|
||||
c.is_ascii_alphanumeric()
|
||||
|| matches!(c, '_' | ':' | '<' | '>' | ',' | ' ' | '*' | '&' | '[' | ']')
|
||||
})
|
||||
}
|
||||
|
||||
/// Enumerate every registered class in `img` via the SchemaSystem tables — the whole-binary schema
|
||||
/// inventory. Sorted by class name.
|
||||
pub fn enumerate_schema(img: &CodeImage) -> Vec<SchemaClass> {
|
||||
let mut out = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
for (slot, val) in img.reloc_slots() {
|
||||
if slot < 8 {
|
||||
continue;
|
||||
}
|
||||
// Candidate: `slot` is a class's m_pszName field, so `val` -> the class name string.
|
||||
let Some(name) = img.read_c_string(val) else {
|
||||
continue;
|
||||
};
|
||||
if !is_type_name(&name) {
|
||||
continue;
|
||||
}
|
||||
let base = slot - 8;
|
||||
if !seen.insert(base) {
|
||||
continue;
|
||||
}
|
||||
if let Some(cls) = parse_class(img, base, &name, val) {
|
||||
out.push(cls);
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
out
|
||||
}
|
||||
|
||||
fn parse_class(img: &CodeImage, base: u64, name: &str, name_ptr: u64) -> Option<SchemaClass> {
|
||||
let size = img.read_i32(base.wrapping_add(CI_SIZE))?;
|
||||
if size <= 0 || size >= (1 << 23) {
|
||||
return None;
|
||||
}
|
||||
let field_count = img.read_u16(base.wrapping_add(CI_FIELD_COUNT))?;
|
||||
if field_count == 0 || field_count >= 6000 {
|
||||
return None;
|
||||
}
|
||||
let fields_ptr = img.read_ptr(base.wrapping_add(CI_FIELDS))?;
|
||||
if fields_ptr == 0 {
|
||||
return None;
|
||||
}
|
||||
// Discriminator: a real schema class's first field is `m_…`. This alone rejects the stray reloc
|
||||
// slots that happen to point at an identifier-shaped string but aren't class bindings.
|
||||
let first = img
|
||||
.read_ptr(fields_ptr)
|
||||
.and_then(|p| img.read_c_string(p))?;
|
||||
if !first.starts_with("m_") {
|
||||
return None;
|
||||
}
|
||||
|
||||
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 {
|
||||
break;
|
||||
};
|
||||
let offset = img.read_i32(fe + F_OFFSET).unwrap_or(0);
|
||||
fields.push(SchemaField {
|
||||
name: fname,
|
||||
offset,
|
||||
});
|
||||
}
|
||||
|
||||
let base_count = img.read_u8(base.wrapping_add(CI_BASE_COUNT)).unwrap_or(0);
|
||||
let bases_ptr = img.read_ptr(base.wrapping_add(CI_BASES)).unwrap_or(0);
|
||||
let mut bases = Vec::new();
|
||||
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);
|
||||
if bcls == 0 {
|
||||
continue;
|
||||
}
|
||||
if let Some(bn) = img
|
||||
.read_ptr(bcls + CI_NAME)
|
||||
.and_then(|p| img.read_c_string(p))
|
||||
{
|
||||
bases.push(SchemaBase { name: bn, offset });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(SchemaClass {
|
||||
name: name.to_string(),
|
||||
class_info: base,
|
||||
name_ptr,
|
||||
size,
|
||||
bases,
|
||||
fields,
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Live type walk (the typed schema) ──────────────────────────────────────────────────────────
|
||||
// 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.
|
||||
|
||||
/// 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
|
||||
/// against swiftlys2's own generated hashes.
|
||||
fn fnv1a32(s: &str) -> u32 {
|
||||
let mut h: u32 = 0x811c9dc5;
|
||||
for b in s.bytes() {
|
||||
h = (h ^ b as u32).wrapping_mul(0x0100_0193);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// Best-effort byte size of a builtin schema type (codegen doesn't require it, but it's cheap).
|
||||
fn builtin_size(t: &str) -> i32 {
|
||||
match t {
|
||||
"int8" | "uint8" | "char" | "bool" => 1,
|
||||
"int16" | "uint16" => 2,
|
||||
"int32" | "uint32" | "float32" => 4,
|
||||
"int64" | "uint64" | "float64" | "double" => 8,
|
||||
_ if t.ends_with('*') => 8,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// material (`source2rosetta-gen` renders it on demand).
|
||||
pub(crate) fn live_schema(
|
||||
prof: &GameProfile,
|
||||
pid: u32,
|
||||
dir: &Path,
|
||||
source_build: &str,
|
||||
) -> Result<model::Schema> {
|
||||
use model::{Field, Schema, SchemaMeta};
|
||||
let live = live::LiveProcess::attach(pid)?;
|
||||
let mut classes: BTreeMap<String, BTreeMap<String, Field>> = BTreeMap::new();
|
||||
let (mut typed, mut untyped) = (0usize, 0usize);
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
let mut nlibs = 0usize;
|
||||
for &lib in prof.libs {
|
||||
let Ok(img) = crate::locate::load_lib(dir, lib) else {
|
||||
continue;
|
||||
};
|
||||
let Some(base) = live.base(lib) else { continue }; // lib not mapped in the process -> skip
|
||||
nlibs += 1;
|
||||
for c in &enumerate_schema(&img) {
|
||||
// a shared class already taken from an earlier (higher-precedence) lib — identical layout, skip
|
||||
if !seen.insert(c.name.clone()) {
|
||||
continue;
|
||||
}
|
||||
// fields array is static; each record's m_pType is runtime-resolved -> read it from the process
|
||||
let fields_ptr = img.read_ptr(c.class_info + CI_FIELDS).unwrap_or(0);
|
||||
let mut fmap: BTreeMap<String, Field> = BTreeMap::new();
|
||||
for (i, f) in c.fields.iter().enumerate() {
|
||||
let rec = base
|
||||
.wrapping_add(fields_ptr)
|
||||
.wrapping_add((i as u64).wrapping_mul(F_STRIDE));
|
||||
let mptype = live.read_u64(rec.wrapping_add(F_TYPE)).unwrap_or(0);
|
||||
// a resolved type is a real pointer; on-disk placeholders are tiny/tagged values
|
||||
let (type_name, cat) = if mptype > 0x10000 {
|
||||
let name = live
|
||||
.read_u64(mptype.wrapping_add(TY_NAME))
|
||||
.ok()
|
||||
.and_then(|q| live.read_cstr(q).ok())
|
||||
.unwrap_or_default();
|
||||
let cat = live
|
||||
.read_u16(mptype.wrapping_add(TY_CATEGORY))
|
||||
.map(|v| (v & 0xff) as u8)
|
||||
.unwrap_or(0xff);
|
||||
(name, cat)
|
||||
} else {
|
||||
(String::new(), 0xffu8)
|
||||
};
|
||||
let ty = type_name.replace(' ', ""); // codegen strips spaces anyway
|
||||
// count typed/untyped on the SPACE-STRIPPED type (what the netvars meta reflects), so a
|
||||
// whitespace-only runtime name counts as untyped.
|
||||
if ty.is_empty() {
|
||||
untyped += 1;
|
||||
} else {
|
||||
typed += 1;
|
||||
}
|
||||
let kind = match cat {
|
||||
1 => model::FieldKind::Ptr,
|
||||
3 => model::FieldKind::FixedArray,
|
||||
_ => model::FieldKind::Ref, // builtin / atomic / declared class / declared enum
|
||||
};
|
||||
let name_hash = ((fnv1a32(&c.name) as u64) << 32) | fnv1a32(&f.name) as u64;
|
||||
fmap.insert(
|
||||
f.name.clone(),
|
||||
Field {
|
||||
offset: f.offset,
|
||||
ty: ty.clone(),
|
||||
kind,
|
||||
size: builtin_size(&ty) as usize,
|
||||
name_hash,
|
||||
},
|
||||
);
|
||||
}
|
||||
classes.insert(c.name.clone(), fmap);
|
||||
}
|
||||
}
|
||||
eprintln!(
|
||||
"typed netvars: {} classes across {nlibs} libs, {typed} typed fields, {untyped} unresolved",
|
||||
classes.len()
|
||||
);
|
||||
Ok(Schema {
|
||||
meta: SchemaMeta {
|
||||
game_key: prof.game_key.to_string(),
|
||||
source_build: source_build.to_string(),
|
||||
typed,
|
||||
untyped,
|
||||
},
|
||||
classes,
|
||||
})
|
||||
}
|
||||
87
src/sig.rs
Normal file
87
src/sig.rs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
//! Byte-pattern signatures: parse "55 48 89 ? E5" and scan a byte haystack for matches.
|
||||
|
||||
/// A signature pattern; `None` entries are wildcards (`?` / `??`).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Pattern {
|
||||
bytes: Vec<Option<u8>>,
|
||||
}
|
||||
|
||||
impl Pattern {
|
||||
pub fn parse(s: &str) -> anyhow::Result<Self> {
|
||||
let mut bytes = Vec::new();
|
||||
for tok in s.split_whitespace() {
|
||||
match tok {
|
||||
"?" | "??" | "*" => bytes.push(None),
|
||||
hex => {
|
||||
let b = u8::from_str_radix(hex, 16)
|
||||
.map_err(|_| anyhow::anyhow!("bad signature token {tok:?}"))?;
|
||||
bytes.push(Some(b));
|
||||
}
|
||||
}
|
||||
}
|
||||
anyhow::ensure!(!bytes.is_empty(), "empty signature");
|
||||
anyhow::ensure!(bytes.iter().any(Option::is_some), "all-wildcard signature");
|
||||
Ok(Self { bytes })
|
||||
}
|
||||
|
||||
/// First concrete (non-wildcard) byte and its index — used as a cheap scan prefilter.
|
||||
fn anchor(&self) -> (usize, u8) {
|
||||
self.bytes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find_map(|(i, b)| b.map(|v| (i, v)))
|
||||
.expect("parse() guarantees at least one concrete byte")
|
||||
}
|
||||
|
||||
/// Byte offsets in `hay` where this pattern matches.
|
||||
pub fn find_all(&self, hay: &[u8]) -> Vec<usize> {
|
||||
let n = self.bytes.len();
|
||||
let mut out = Vec::new();
|
||||
if hay.len() < n {
|
||||
return out;
|
||||
}
|
||||
let (ai, av) = self.anchor();
|
||||
// SIMD-scan for the anchor byte (memchr), full-match only at candidate starts.
|
||||
// A match starting at `pos` puts its anchor at `pos + ai`, so valid anchor indices
|
||||
// are [ai, hay.len()-n+ai]; scanning [..=hi] finds every match's anchor, none missed.
|
||||
let hi = hay.len() - n + ai;
|
||||
for apos in memchr::memchr_iter(av, &hay[..=hi]) {
|
||||
if apos < ai {
|
||||
continue;
|
||||
}
|
||||
let pos = apos - ai;
|
||||
if self
|
||||
.bytes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.all(|(i, b)| b.is_none_or(|v| hay[pos + i] == v))
|
||||
{
|
||||
out.push(pos);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_and_match() {
|
||||
let p = Pattern::parse("55 48 ? E5").unwrap();
|
||||
let hay = [0x00, 0x55, 0x48, 0x99, 0xE5, 0x55, 0x48, 0x11, 0xE5];
|
||||
assert_eq!(p.find_all(&hay), vec![1, 5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_question_is_wildcard() {
|
||||
let p = Pattern::parse("90 ?? 90").unwrap();
|
||||
assert_eq!(p.find_all(&[0x90, 0xAB, 0x90]), vec![0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_wildcard_rejected() {
|
||||
assert!(Pattern::parse("? ??").is_err());
|
||||
}
|
||||
}
|
||||
265
src/taxonomy.rs
Normal file
265
src/taxonomy.rs
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
//! Name / dead-weight taxonomy — the classification predicates that decide which resolved names are
|
||||
//! real gameplay functions vs. generated plumbing, and how much to trust a name guess. Every item here is
|
||||
//! a pure `&str`-in / verdict-out predicate over the name/class vocabulary, with no engine or IO
|
||||
//! dependency — each takes the game's `&GameProfile` for its retunable vocabulary (dead-weight namespaces,
|
||||
//! serializer method names, query prefixes). This is the primary knob a fork retunes for a different game
|
||||
//! (the vocabulary is data on the profile). Shared by the fold (`build_gamedata_cmd`), the experimental band
|
||||
//! (`emit_experimental_band`), the live semantic sweep, and the corpus-model class scope.
|
||||
|
||||
use crate::model::Tier;
|
||||
use crate::profile::GameProfile;
|
||||
use serde::Deserialize;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
/// A candidate whose RTTI class is not CS2 gameplay at all — foreign runtime/library code that leaked
|
||||
/// into `libserver.so`, or a protobuf-generated message type whose whole vtable is serializer boilerplate
|
||||
/// (`GetMetadata`/`New`/`Clear`/`MergeFrom`/…, zero hook value). Excluded at dump time so naming agents
|
||||
/// never spend time (~64% of the CS2 candidate pool) on functions we already know are dead
|
||||
/// weight — and so the same junk never enters a per-game run for Dota2/Deadlock.
|
||||
pub(crate) fn is_dead_weight_class(prof: &GameProfile, class: &str) -> bool {
|
||||
// foreign namespaces (C++ runtime, libstdc++, Steam GC SDK, Valve container templates, the V8 vscript
|
||||
// backend whose `v8::` classes leak into libvscript's RTTI) — all retunable per game on the profile.
|
||||
if prof.foreign_namespaces.iter().any(|p| class.starts_with(p)) {
|
||||
return true;
|
||||
}
|
||||
// protobuf RPC message shape — a `_Response`/`_Request` class is always a wire message.
|
||||
if class.contains("_Response") || class.contains("_Request") {
|
||||
return true;
|
||||
}
|
||||
// protobuf-generated message classes (the wire/GC protocol) — every method is serializer plumbing. The
|
||||
// per-game user-message prefix (CS2: CCSUsrMsg) plus the shared Source-2 / Steam-GC message prefixes.
|
||||
let leaf = class.rsplit("::").next().unwrap_or(class);
|
||||
leaf.starts_with(prof.usermsg_prefix) || prof.proto_prefixes.iter().any(|p| leaf.starts_with(p))
|
||||
}
|
||||
|
||||
/// A resolved NAME that is not CS2 gameplay — its owning class (or the whole name) is foreign/protobuf.
|
||||
/// Complements the class-based dump-candidates prefilter for NON-virtual dead weight that has no RTTI
|
||||
/// vtable class to filter on at dump time (free `GCSDK::*` / `google::protobuf::*` functions), caught
|
||||
/// here once naming has resolved the class.
|
||||
pub(crate) fn is_dead_weight_name(prof: &GameProfile, name: &str) -> bool {
|
||||
let cls = name.rsplit_once("::").map(|(c, _)| c).unwrap_or(name);
|
||||
is_dead_weight_class(prof, cls) || is_dead_weight_class(prof, name)
|
||||
}
|
||||
|
||||
/// Classes with ≥3 serializer methods among `names` — protobuf message types whose class name matches no
|
||||
/// foreign/CMsg prefix (e.g. `AccountActivity`, `CGCToGCMsgMasterAck`), detectable ONLY by their generated
|
||||
/// method surface. The shared cluster detector both the fold and the experimental band flag plumbing with.
|
||||
/// A HARD serializer method (`GetMetadata`/…) is decisive on its own; SOFT ones (`New`/`Clear`/…) can be
|
||||
/// legit game methods, so both count toward the ≥3 cluster here but only HARD is a lone verdict elsewhere
|
||||
/// (see [`is_serializer_plumbing`]). Both sets ride the `GameProfile` passed in, so a fork retunes them.
|
||||
pub(crate) fn protobuf_message_classes<'a>(
|
||||
prof: &GameProfile,
|
||||
names: impl Iterator<Item = &'a str>,
|
||||
) -> HashSet<String> {
|
||||
let mut ser_count: HashMap<&str, usize> = HashMap::new();
|
||||
for name in names {
|
||||
if let Some((cls, leaf)) = name.rsplit_once("::")
|
||||
&& (prof.hard_serializer.contains(&leaf) || prof.soft_serializer.contains(&leaf))
|
||||
{
|
||||
*ser_count.entry(cls).or_default() += 1;
|
||||
}
|
||||
}
|
||||
ser_count
|
||||
.into_iter()
|
||||
.filter(|(_, c)| *c >= 3)
|
||||
.map(|(k, _)| k.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Is `name` protobuf serializer plumbing — a lone HARD serializer method, or a member of a class flagged
|
||||
/// as a protobuf message by [`protobuf_message_classes`]? Complements the prefix/namespace test in
|
||||
/// [`is_dead_weight_name`], which can't see method-name-only protobuf classes.
|
||||
pub(crate) fn is_serializer_plumbing(
|
||||
prof: &GameProfile,
|
||||
name: &str,
|
||||
pb_classes: &HashSet<String>,
|
||||
) -> bool {
|
||||
let hard = prof.hard_serializer;
|
||||
name.rsplit_once("::")
|
||||
.is_some_and(|(cls, leaf)| hard.contains(&leaf) || pb_classes.contains(cls))
|
||||
}
|
||||
|
||||
/// A class clean enough to key a vtable-OFFSET entry: a real gameplay class (not dead weight), not a
|
||||
/// `NetworkVar_`/template/alias chainer, and a bare name (the RTTI ground-truth class, no `::`).
|
||||
pub(crate) fn clean_offset_class(prof: &GameProfile, cls: &str) -> bool {
|
||||
!is_dead_weight_class(prof, cls)
|
||||
&& !cls.contains("NetworkVar_")
|
||||
&& !cls.contains('<')
|
||||
&& !cls.contains("Alias_")
|
||||
&& !cls.contains("::")
|
||||
}
|
||||
|
||||
/// The class portion of a fully-qualified function name — everything before the last `::`, or before the
|
||||
/// last `_` for the flat `Class_Method` form, or the whole name if neither. The one name-vocabulary splitter
|
||||
/// the gamedata offsets and the live sweep share, so callers don't re-derive the class inline.
|
||||
pub(crate) fn class_of(name: &str) -> &str {
|
||||
if let Some(i) = name.rfind("::") {
|
||||
&name[..i]
|
||||
} else if let Some(i) = name.rfind('_') {
|
||||
&name[..i]
|
||||
} else {
|
||||
name
|
||||
}
|
||||
}
|
||||
|
||||
/// The `ret=` class word out of an abi describe string ("int=1 float=0 ret=int" -> "int").
|
||||
pub(crate) fn parse_ret(abi: &str) -> Option<&str> {
|
||||
abi.split_whitespace().find_map(|t| t.strip_prefix("ret="))
|
||||
}
|
||||
|
||||
/// One row of the full-slice name universe (`candidates-names-cs2-full.json`): an address + the
|
||||
/// AI/heuristic name guess for it, plus the signals that grade the guess. Distinct from `PromoName` —
|
||||
/// this reads the UN-filtered set (promoted AND un-promoted), the raw material of the experimental band.
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct FullName {
|
||||
pub(crate) addr: String,
|
||||
pub(crate) name: String,
|
||||
#[serde(default)]
|
||||
pub(crate) confidence: String,
|
||||
#[serde(default)]
|
||||
pub(crate) corroboration: String,
|
||||
#[serde(default)]
|
||||
pub(crate) self_named: bool,
|
||||
#[serde(default)]
|
||||
pub(crate) promote: bool,
|
||||
}
|
||||
|
||||
/// The confidence LADDER for a name guess — a composite honesty tier, stronger than the model's own
|
||||
/// confidence word: a name literally present in the function's bytes (`self-named`) is near-certain; a
|
||||
/// dictionary-corroborated leaf is next; then the model's own high/medium/low. Returns `(rank, label)`,
|
||||
/// lower rank = more trustworthy. This is the primary grouping key of the experimental band.
|
||||
pub(crate) fn guess_tier(r: &FullName) -> (u8, Tier) {
|
||||
if r.self_named {
|
||||
(0, Tier::SelfNamed)
|
||||
} else if matches!(r.corroboration.as_str(), "exact" | "exact-free") {
|
||||
(1, Tier::Corroborated)
|
||||
} else if r.confidence == "high" {
|
||||
(2, Tier::High)
|
||||
} else if r.confidence == "medium" {
|
||||
(3, Tier::Medium)
|
||||
} else {
|
||||
(4, Tier::Low)
|
||||
}
|
||||
}
|
||||
|
||||
/// A method name safe to blind-CALL with only `this` — a boolean predicate that returns a bool in RAX.
|
||||
/// Deliberately EXCLUDES `Get*`: a getter can return a value BY VALUE (a string/struct), whose ABI
|
||||
/// hides an output-buffer pointer in RDI (RVO) with `this` shifted to RSI — so calling it with the
|
||||
/// object in RDI makes it WRITE the return value into the object. That is memory CORRUPTION, not a
|
||||
/// faulting read, so `call_remote`'s signal-suppression can't catch it and the server dies later. The ABI-shape
|
||||
/// lower bound can't distinguish this (a constant-returner reads no args and shows `int=0`), so the
|
||||
/// gate is name-based: only the boolean predicates, which by convention return a bool and take no
|
||||
/// output parameter. Fewer methods get the call-smoke-test, but the harness never corrupts the server.
|
||||
pub(crate) fn is_query_method(prof: &GameProfile, name: &str) -> bool {
|
||||
let leaf = name.rsplit("::").next().unwrap_or(name);
|
||||
prof.query_prefixes.iter().any(|p| leaf.starts_with(p))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::profile::{CS2, DOTA};
|
||||
|
||||
// This module is documented as "the primary knob a fork retunes for a different game", and nothing
|
||||
// else gates a retune: live validation only ever sees entries that SURVIVED classification, so an
|
||||
// over-broad predicate silently shrinks the output with no count to compare against. These pin the
|
||||
// decisions against both shipped profiles.
|
||||
|
||||
#[test]
|
||||
fn real_gameplay_classes_are_not_dead_weight() {
|
||||
for prof in [&CS2, &DOTA] {
|
||||
for cls in [
|
||||
"CBaseEntity",
|
||||
"CCSPlayerPawn",
|
||||
"CGameRules",
|
||||
"CDOTA_BaseNPC",
|
||||
] {
|
||||
assert!(
|
||||
!is_dead_weight_class(prof, cls),
|
||||
"{cls} misclassified as dead weight"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protobuf_and_foreign_namespaces_are_dead_weight() {
|
||||
for prof in [&CS2, &DOTA] {
|
||||
for cls in ["CMsgVector", "v8::internal::Object", "std::vector<int>"] {
|
||||
assert!(
|
||||
is_dead_weight_class(prof, cls),
|
||||
"{cls} should be dead weight"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_hard_serializer_marks_plumbing_but_one_soft_does_not() {
|
||||
let prof = &CS2;
|
||||
let none = HashSet::new();
|
||||
let hard = format!("CFoo::{}", prof.hard_serializer[0]);
|
||||
let soft = format!("CFoo::{}", prof.soft_serializer[0]);
|
||||
// a lone HARD serializer method is decisive on its own
|
||||
assert!(is_serializer_plumbing(prof, &hard, &none));
|
||||
// a lone SOFT one is not — those names also occur on legitimate game classes
|
||||
assert!(!is_serializer_plumbing(prof, &soft, &none));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protobuf_clustering_needs_three_serializer_methods() {
|
||||
let prof = &CS2;
|
||||
let soft = prof.soft_serializer;
|
||||
assert!(
|
||||
soft.len() >= 3,
|
||||
"profile needs >=3 soft serializers for this rule to be reachable"
|
||||
);
|
||||
let two: Vec<String> = soft.iter().take(2).map(|m| format!("CTwo::{m}")).collect();
|
||||
let three: Vec<String> = soft
|
||||
.iter()
|
||||
.take(3)
|
||||
.map(|m| format!("CThree::{m}"))
|
||||
.collect();
|
||||
let all: Vec<&str> = two.iter().chain(three.iter()).map(String::as_str).collect();
|
||||
let flagged = protobuf_message_classes(prof, all.into_iter());
|
||||
assert!(
|
||||
flagged.contains("CThree"),
|
||||
"3 serializer methods should cluster as protobuf"
|
||||
);
|
||||
assert!(
|
||||
!flagged.contains("CTwo"),
|
||||
"2 methods is below the >=3 cluster threshold"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_of_prefers_scope_then_underscore_then_whole_name() {
|
||||
assert_eq!(class_of("CBaseEntity::TakeDamage"), "CBaseEntity");
|
||||
// a templated class keeps its template arguments
|
||||
assert_eq!(
|
||||
class_of("CHandle<CBaseEntity>::Get"),
|
||||
"CHandle<CBaseEntity>"
|
||||
);
|
||||
// no `::` falls back to the last underscore — this is how the ecosystem's flat
|
||||
// `CClass_Method` names still key a class
|
||||
assert_eq!(class_of("CCSPlayerPawn_Respawn"), "CCSPlayerPawn");
|
||||
// and with neither separator the whole name IS the class key
|
||||
assert_eq!(class_of("FreeFunction"), "FreeFunction");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_methods_need_a_profile_prefix_not_just_get() {
|
||||
let prof = &CS2;
|
||||
// `is_query_method` gates the live CALL sweep — a false positive means blind-calling a method
|
||||
// that really takes arguments, so it must not fire on every `Get*`.
|
||||
let any_prefix_hit = prof
|
||||
.query_prefixes
|
||||
.iter()
|
||||
.any(|p| is_query_method(prof, &format!("CBaseEntity::{p}Something")));
|
||||
assert!(
|
||||
any_prefix_hit,
|
||||
"no profile query prefix matched its own pattern"
|
||||
);
|
||||
assert!(!is_query_method(prof, "CBaseEntity::Teleport"));
|
||||
}
|
||||
}
|
||||
135
src/xref.rs
Normal file
135
src/xref.rs
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
//! Whole-binary cross-reference index — the layer that lets us *name and locate non-virtual
|
||||
//! functions*, which have no vtable slot and (mostly) no symbol.
|
||||
//!
|
||||
//! Decode every function and record, for each referenced address, the instructions that reference
|
||||
//! it: near `call`/`jmp` targets (code) and RIP-relative memory operands (`lea`/`mov` into `.rodata`
|
||||
//! strings, globals, …). Each reference is attributed to its containing function, so we can answer
|
||||
//! "which function uses this string?" (string-anchored location) and "who calls this function?".
|
||||
//!
|
||||
//! Function entries come from `locate::candidate_entries` (relocation code-pointers — every vtable
|
||||
//! slot — ∪ decoded `call` targets) unioned with `.eh_frame` starts. This matters: CS2 strips
|
||||
//! `.eh_frame` from the *game* code (unwind info survives only for the statically-linked runtime
|
||||
//! tail), so an eh_frame-only index misses the entire gameplay region. Decoding from each entry to
|
||||
//! the next avoids the misalignment a blind section-wide linear sweep suffers on data/padding.
|
||||
|
||||
use crate::elf::CodeImage;
|
||||
use iced_x86::{Decoder, DecoderOptions, FlowControl, Instruction, OpKind};
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub struct XrefIndex {
|
||||
entries: Vec<u64>, // sorted, de-duped function entry addresses
|
||||
refs: HashMap<u64, Vec<u64>>, // referenced VA -> source instruction VAs
|
||||
call_targets: Vec<u64>, // sorted, de-duped near-call targets
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
// 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
|
||||
// scheduler load-balances them. Each task returns its (ref-pair, call-target) deltas; merging
|
||||
// them in entry order (parallel_map preserves input order) reproduces the serial build
|
||||
// byte-for-byte: refs[t] receives its srcs in the same (ascending entry, then instruction)
|
||||
// order and call_targets is sorted afterwards.
|
||||
type EntryData = (Vec<(u64, u64)>, Vec<u64>);
|
||||
let idxs: Vec<usize> = (0..entries.len()).collect();
|
||||
let per_entry: Vec<EntryData> =
|
||||
crate::par::parallel_map(&idxs, crate::par::default_threads(None), |&i| {
|
||||
let start = entries[i];
|
||||
let end = entries.get(i + 1).copied().unwrap_or(u64::MAX);
|
||||
let Some(code) = img.code_range(start, end) else {
|
||||
return (Vec::new(), Vec::new());
|
||||
};
|
||||
let mut ref_pairs: Vec<(u64, u64)> = Vec::new();
|
||||
let mut call_targets: Vec<u64> = Vec::new();
|
||||
let mut insn = Instruction::default();
|
||||
let mut dec = Decoder::with_ip(64, code, start, DecoderOptions::NONE);
|
||||
while dec.can_decode() {
|
||||
dec.decode_out(&mut insn);
|
||||
let src = insn.ip();
|
||||
// Near call/jmp: the target is code; call targets double as function entries.
|
||||
if matches!(
|
||||
insn.op0_kind(),
|
||||
OpKind::NearBranch16 | OpKind::NearBranch32 | OpKind::NearBranch64
|
||||
) {
|
||||
let t = insn.near_branch_target();
|
||||
ref_pairs.push((t, src));
|
||||
if insn.flow_control() == FlowControl::Call {
|
||||
call_targets.push(t);
|
||||
}
|
||||
}
|
||||
// RIP-relative memory operand: a reference to a string / global / code pointer.
|
||||
if insn.is_ip_rel_memory_operand() {
|
||||
let t = insn.ip_rel_memory_address();
|
||||
ref_pairs.push((t, src));
|
||||
}
|
||||
}
|
||||
(ref_pairs, call_targets)
|
||||
});
|
||||
|
||||
let mut refs: HashMap<u64, Vec<u64>> = HashMap::new();
|
||||
let mut call_targets = Vec::new();
|
||||
for (ref_pairs, cts) in per_entry {
|
||||
for (t, src) in ref_pairs {
|
||||
refs.entry(t).or_default().push(src);
|
||||
}
|
||||
call_targets.extend(cts);
|
||||
}
|
||||
call_targets.sort_unstable();
|
||||
call_targets.dedup();
|
||||
Self {
|
||||
entries,
|
||||
refs,
|
||||
call_targets,
|
||||
}
|
||||
}
|
||||
|
||||
/// The entry (function start) that contains `va`: the nearest entry at or below `va`.
|
||||
pub fn containing_func(&self, va: u64) -> Option<u64> {
|
||||
let i = self.entries.partition_point(|&s| s <= va);
|
||||
(i > 0).then(|| self.entries[i - 1])
|
||||
}
|
||||
|
||||
/// Source instruction addresses that reference `target`.
|
||||
pub fn refs_to(&self, target: u64) -> &[u64] {
|
||||
self.refs.get(&target).map_or(&[], |v| v.as_slice())
|
||||
}
|
||||
|
||||
/// Distinct functions that reference `target` (each referring instruction mapped to its
|
||||
/// containing function, so one function referencing `target` N times counts once).
|
||||
pub fn referrers(&self, target: u64) -> Vec<u64> {
|
||||
let mut fs: Vec<u64> = self
|
||||
.refs_to(target)
|
||||
.iter()
|
||||
.filter_map(|&s| self.containing_func(s))
|
||||
.collect();
|
||||
fs.sort_unstable();
|
||||
fs.dedup();
|
||||
fs
|
||||
}
|
||||
|
||||
pub fn call_targets(&self) -> &[u64] {
|
||||
&self.call_targets
|
||||
}
|
||||
}
|
||||
|
||||
/// Functions that reference the string `s` anywhere in read-only data — the canonical "find the
|
||||
/// function by a string it uses" primitive. A string referenced by exactly one function names that
|
||||
/// function unambiguously. We match `s` as a substring (a code `lea`/`mov` points at the string's
|
||||
/// start whatever follows it — a trailing `\n`, format args, or a longer literal), so callers pass a
|
||||
/// distinctive fragment without needing the whole literal.
|
||||
pub fn funcs_using_string(img: &CodeImage, xref: &XrefIndex, s: &str) -> Vec<u64> {
|
||||
let mut out = Vec::new();
|
||||
for str_va in img.find_bytes(s.as_bytes()) {
|
||||
out.extend(xref.referrers(str_va));
|
||||
}
|
||||
out.sort_unstable();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue