read what the binary says about itself: names, signatures, prototypes; gen v2
This commit is contained in:
parent
54ef572202
commit
c458b4cb50
34 changed files with 58363 additions and 192 deletions
616
src/pulse.rs
Normal file
616
src/pulse.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue