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

616
src/pulse.rs Normal file
View file

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