//! 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. // Registers whose value a call destroys. The ONE list in `abi`, not a second copy of it — both loops // below that invalidate across a call read it directly. use crate::abi::CALLER_SAVED; use crate::elf::CodeImage; use iced_x86::{Decoder, DecoderOptions, FlowControl, Instruction, Mnemonic, OpKind, Register}; use std::collections::{BTreeMap, HashMap, HashSet}; /// Highest `PulseValueType_t` enumerator (`PVAL_COUNT`) plus headroom for a build that adds a few. The /// enum is schema-registered, so the DERIVED values are what a caller should validate against — this is /// 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, /// 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, } /// 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, rdi: Option, rsi: Option, rdx: Option, } /// What a constant-propagation pass could establish about the initializer. #[derive(Default)] struct Trace { /// Absolute address -> the pointer or immediate stored there. writes: BTreeMap, /// 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, /// `(count, elements)` as the accessor returns them on its already-initialised path. ret: Option<(u64, u64)>, } fn full(r: Register) -> Register { if r.is_gpr() { r.full_register() } else { r } } /// Every instruction address reachable from `entry` inside `[entry, entry+code.len())`, in ADDRESS order. /// /// One walk, two callers: the descriptor trace and the shim's liveness read need exactly the same thing — /// flow-reachable addresses rather than a linear sweep, so a jump table or an interleaved neighbour cannot /// contribute instructions the function never executes. They differ only in how far they are willing to /// walk, which is the `cap`. /// /// `cap` bounds the SET, not the span: a crafted image can present a small span with pathological branch /// density, and this is on the fuzz surface. fn reachable(code: &[u8], entry: u64, cap: usize) -> Vec { let end = entry.saturating_add(code.len() as u64); let mut seen: HashSet = HashSet::new(); let mut work = vec![entry]; let mut insn = Instruction::default(); while let Some(at) = work.pop() { if at < entry || at >= end || seen.contains(&at) || seen.len() > cap { continue; } let mut dec = Decoder::with_ip(64, &code[(at - entry) as usize..], at, DecoderOptions::NONE); if !dec.can_decode() { continue; } dec.decode_out(&mut insn); if insn.is_invalid() || insn.len() == 0 { continue; } seen.insert(at); match insn.flow_control() { FlowControl::Return | FlowControl::IndirectBranch | FlowControl::Exception | FlowControl::Interrupt => {} FlowControl::UnconditionalBranch => work.push(insn.near_branch_target()), FlowControl::ConditionalBranch => { work.push(at + insn.len() as u64); work.push(insn.near_branch_target()); } _ => work.push(at + insn.len() as u64), } } let mut addrs: Vec = seen.into_iter().collect(); addrs.sort_unstable(); addrs } /// Constant-propagate through the accessor, recording every fixed-address store, every call's argument /// registers, and the vector the fast path returns. /// /// Deliberately a single ADDRESS-ORDER pass rather than a CFG walk: the guard-protected initializer is /// straight-line, and a pass that only ever believes values it computed itself cannot invent one. Every /// instruction it does not model invalidates what it writes. fn trace(img: &CodeImage, entry: u64, seed_rdi: Option) -> Option { let all = img.code_at(entry)?; let code = &all[..all.len().min(MAX_SPAN)]; let mut insn = Instruction::default(); let addrs = reachable(code, entry, 4000); let mut out = Trace::default(); let mut regs: HashMap = 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, } fn record(img: &CodeImage, accessor: u64) -> Option { 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, }) } /// 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 { 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> { // 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>, u64, usize, usize) { let flat: Vec = 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 = 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)> { 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)> = 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 } /// How far past a shim's entry the read-measurement will follow. `.eh_frame_hdr` covers only a fraction of /// these images' functions and none of the shims, so there is no exact extent available; flow-following ends /// at every `ret` regardless, so this only bounds a runaway path. const SHIM_SPAN: u64 = 0x1000; /// The seven integer arguments a Pulse invocation shim takes, in SysV order. The seventh is the first /// STACK slot — measured, and the reason the shim's arity cannot be read off `abi_shape`, whose backward /// liveness stops at the registers. const SHIM_SLOTS: [Register; 6] = [ Register::RDI, Register::RSI, Register::RDX, Register::RCX, Register::R8, Register::R9, ]; /// What an invocation shim was measured to read, and therefore what a caller has to supply. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct ShimReads { /// The argument slots actually read, named — `rcx`, `r8`, `stack0`. The argument array (`r8`) is /// stated here and nowhere else: reading it is the ordinary case and constrains a caller in no way, /// so it needs no flag of its own beside the three that do. pub reads: Vec<&'static str>, /// Does it read the output sink (the first stack slot)? True for exactly the bindings that declare a /// return, measured across both games with no exceptions. pub sink: bool, /// Does it read the Pulse host-service context (`rcx`)? That object is VM-owned, so a host cannot /// supply one. pub context: bool, /// Does it read any OTHER slot — `rdi`, `rsi`, `rdx`, `r9`? These are the slots a caller would /// otherwise pass as null, so any read here means it cannot. pub other: bool, } impl ShimReads { /// What a host must supply, as the artifact states it. /// /// `args-only` is the one that matters: everything such a shim reads is either the argument array a /// caller builds or the game's own entity list, so the remaining slots may be null. That is not a /// deduction — it was validated by calling every eligible binding in both games with a sentinel handle /// (CS2 186 of 193 clean, Dota 211 of 211), and the exceptions are exactly the shims this reports as /// reading another slot. pub fn needs(&self) -> &'static str { if self.context { "pulse-context" } else if self.other { "other-slots" } else if self.sink { "output-sink" } else { "args-only" } } } /// Where an accessor's descriptor region LIVES, and how many elements it holds: `(base, count)`. /// /// The signature reader reconstructs the region's CONTENTS by constant-propagating the initialiser, /// because on disk the elements are zeroes — they are written at runtime. That reconstruction is the /// only offline route, and it is also unverified: the multi-library duplicate check reports that CS2 /// disagrees with itself on 331 of 419 repeat registrations, and nothing offline can say which account /// is right. /// /// A running server can. The region is a plain static, so at `slide + base` a live process holds the /// POPULATED elements, and reading them settles the question against the same build rather than against /// a dump of a different one. This accessor exists for that oracle; the derivation itself never needs it. pub fn record_region(img: &CodeImage, accessor: u64) -> Option<(u64, u64)> { let r = record(img, accessor)?; (r.base != 0).then_some((r.base, r.count)) } /// What a read of one argument slot demands of a HOST caller. /// /// The argument array (`r8`) demands nothing — the caller builds it, so reading it is the ordinary case /// and `reads` already states it. The Pulse context (`rcx`) is VM-owned and cannot be supplied at all. /// Everything else is a slot the caller would otherwise pass null. /// /// A named arm rather than a fall-through for `r8` specifically: dropping it into the `_` catch-all would /// mark every ordinary binding as needing a slot no host can fill, retiring the entire `args-only` /// callable tier — a collapse that reads as "this build has no callable bindings", which is a legitimate /// answer for a game and therefore invisible. fn slot_need(r: Register, out: &mut ShimReads) { match r { Register::RCX => out.context = true, Register::R8 => {} _ => out.other = true, } } /// Measure which of a shim's seven arguments it reads. /// /// Reachable instructions in ADDRESS order, which needs two guards that cost real time to find: /// /// * `push`/`pop` must NOT update the alias map. The compiler lays the epilogue out BEFORE the /// found-path block, so `pop r13` sits at a lower address than the `mov rax,[r13+0x10]` that reads the /// second argument through a stashed `mov r13, r8` — and letting the pop clear the alias loses the read. /// The same shape cost the ConCommand reader an epoch counter. /// * `xor r, r` / `sub r, r` name the register in BOTH operands and read neither. Counted, they mark an /// argument live that the shim never consumes; `xor edi, edi` alone accounted for 143 false positives. pub fn shim_reads(img: &CodeImage, entry: u64) -> Option { let all = img.code_at(entry)?; let code = &all[..(all.len() as u64).min(SHIM_SPAN) as usize]; let mut insn = Instruction::default(); let addrs = reachable(code, entry, 20000); let mut live: BTreeMap = BTreeMap::new(); let mut sink = false; let mut fresh: Vec = SHIM_SLOTS.to_vec(); for at in addrs { let mut dec = Decoder::with_ip(64, &code[(at - entry) as usize..], at, DecoderOptions::NONE); dec.decode_out(&mut insn); // The first stack slot is the output sink. Only `[rbp+0x10]` is ever read — no shim in either // game touches a second — which is what pins the arity at seven. // // The displacement MUST be read as signed. `memory_displacement64` is unsigned, so a local at // `[rbp-0x10]` comes back as `0xffff_ffff_ffff_fff0`, which passes an unsigned `>= 0x10` — and // every shim with a stack local then looks as though it reads the output sink. That mistake // reported 246 sink-readers against a true 201 and hid two bindings whose callability had already // been demonstrated by a live call. if (insn.op0_kind() == OpKind::Memory || insn.op1_kind() == OpKind::Memory) && insn.memory_base() == Register::RBP && insn.memory_index() == Register::None && insn.memory_displacement64() as i64 >= 0x10 { sink = true; } let zeroing = matches!(insn.mnemonic(), Mnemonic::Xor | Mnemonic::Sub) && insn.op0_kind() == OpKind::Register && insn.op1_kind() == OpKind::Register && insn.op0_register().full_register() == insn.op1_register().full_register(); // A register named inside a MEMORY operand is read even though it is not a register operand. if !zeroing { for r in [insn.memory_base(), insn.memory_index()] { if r != Register::None && r != Register::RIP && fresh.contains(&r.full_register()) { live.insert(r.full_register(), true); } } for i in 0..insn.op_count() { if insn.op_kind(i) != OpKind::Register { continue; } let pure_dst = i == 0 && matches!( insn.mnemonic(), Mnemonic::Mov | Mnemonic::Lea | Mnemonic::Movzx | Mnemonic::Movsxd ); let r = insn.op_register(i).full_register(); if !pure_dst && fresh.contains(&r) { live.insert(r, true); } } } if insn.op_count() > 0 && insn.op0_kind() == OpKind::Register && !matches!(insn.mnemonic(), Mnemonic::Push | Mnemonic::Pop) { let d = insn.op0_register().full_register(); fresh.retain(|&r| r != d); } if insn.flow_control() == FlowControl::Call { for r in CALLER_SAVED { fresh.retain(|&x| x != r); } } } let mut out = ShimReads { sink, ..Default::default() }; for (r, name) in SHIM_SLOTS .iter() .zip(["rdi", "rsi", "rdx", "rcx", "r8", "r9"]) { if live.contains_key(r) { out.reads.push(name); slot_need(*r, &mut out); } } if sink { out.reads.push("stack0"); } Some(out) } /// 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 needs_reports_the_most_restrictive_requirement_a_shim_has() { // Precedence matters: a shim reading both the context and the sink is not "output-sink", because // the context is the one a host cannot supply at all. Ordering it the other way would advertise // a binding as merely needing a sink when it actually needs a live cursor. let ctx = ShimReads { context: true, sink: true, reads: vec!["rcx", "r8", "stack0"], ..Default::default() }; assert_eq!(ctx.needs(), "pulse-context"); let other = ShimReads { other: true, sink: true, reads: vec!["rdi", "r8", "stack0"], ..Default::default() }; assert_eq!(other.needs(), "other-slots"); let sink = ShimReads { sink: true, reads: vec!["r8", "stack0"], ..Default::default() }; assert_eq!(sink.needs(), "output-sink"); // The callable tier: the argument array and nothing else. let only = ShimReads { reads: vec!["r8"], ..Default::default() }; assert_eq!(only.needs(), "args-only"); // A shim reading NOTHING is still args-only — a zero-argument binding reads no array either. assert_eq!(ShimReads::default().needs(), "args-only"); } #[test] fn reading_the_argument_array_leaves_a_shim_host_callable() { // Asserted against the shipped rule rather than a copy of it. `r8` is the argument array the // CALLER builds, so a read of it must impose nothing; the arm exists only to keep it out of the // catch-all, where it would mark every ordinary binding uncallable at once. let mut r8 = ShimReads::default(); slot_need(Register::R8, &mut r8); assert_eq!(r8.needs(), "args-only"); let mut rcx = ShimReads::default(); slot_need(Register::RCX, &mut rcx); assert_eq!(rcx.needs(), "pulse-context"); for r in [Register::RDI, Register::RSI, Register::RDX, Register::R9] { let mut o = ShimReads::default(); slot_need(r, &mut o); assert_eq!(o.needs(), "other-slots", "{r:?} is a slot a host must fill"); } } #[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)); } }