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

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

View file

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