//! The VScript binding registry — the fourth surface a Source-2 module documents about itself, and the //! only one that states a function's PARAMETER NAMES. //! //! Valve exposes a subset of the C++ surface to script (Lua in Dota's custom games, and a smaller set in //! CS2). Every exposed method is registered with the script VM through a descriptor carrying its //! script-facing name, its C++ name, an English description, a return type, and a pointer to the //! implementation. That is a locator AND a prototype AND documentation, all stated by Valve, which makes //! it the same kind of find as the Pulse registry and the console-command registration. //! //! # Why this is not a table walk //! //! The obvious route — find a static array of descriptors and read it — does not work, and the reason is //! worth stating because it costs a day to rediscover. The descriptors are built at RUNTIME: a scan of //! Dota's `libserver.so` finds 2,268,664 `R_X86_64_RELATIVE` relocations and **not one** points at a //! description string. On disk the descriptor array is zeroes. //! //! What is static is the CODE that fills it in, and every field is a constant in the instruction stream. //! This is the same shape the Pulse parameter records turned out to have, and the same answer applies: //! constant-propagate through the initialiser rather than read the table. //! //! ```text //! movq xmm0, [rip+slot] ; the script-facing name, via a relocated .data.rel.ro slot //! lea rdx, [rip+"Script_TakeDamage"] //! pinsrq xmm0, rdx, 1 ; pack both names into one 16-byte store //! lea rsi, [rip+"Applies damage to this entity."] //! lea rax, [rax+rax*4] ; index * 5 //! shl rax, 4 ; * 16 -> stride 80 //! add rax, [rbx+0x28] ; base = owning class descriptor's function array //! mov [rax+0x30], rbx ; owner //! mov [rax+0x10], rsi ; description //! movups [rax], xmm0 ; +0x00 script name, +0x08 C++ name //! mov [rax+0x18], r11w ; return type //! ``` //! //! # The record //! //! **80 bytes**, derived rather than assumed — the `lea r,[r+r*4]` / `shl r,4` pair states it in the //! instruction stream, so a stride change is a decode failure rather than silent corruption. //! //! | offset | field | //! |---|---| //! | `+0x00` | script-facing name (`TakeDamage`) | //! | `+0x08` | C++ binding name (`Script_TakeDamage`) | //! | `+0x10` | Valve's English description | //! | `+0x18` | return type, a `u16` | //! | `+0x28` | a name string — the return value's, where one is given | //! | `+0x30` | the owning class descriptor | //! | `+0x38` | the marshalling thunk, SHARED by every binding of the same shape | //! | `+0x40` | pointer-to-member: the implementation | //! | `+0x48` | a `u32` count | //! //! `+0x40` is an Itanium pointer-to-member, which is convenient rather than awkward: a non-virtual //! member is a plain address and a virtual one is `slot * 8 + 1`. Those are exactly the two locator //! forms the rest of this crate already emits, so a VScript binding lands in `gamedata` as either a //! signature or a vtable offset with no new concept. //! //! Do not confuse `+0x38` with `+0x40`. The thunk at `+0x38` is a compiler-generated trampoline shared //! across every binding with the same signature; folding it would ship dozens of distinct names all //! pointing at one address. That is the same mistake the Pulse `+24`/`+32` accessors invite, and it is //! caught here the same way — by the sharing itself, since a real implementation is referenced once. //! //! # Shape-driven, so a layout change yields FEWER bindings and never wrong ones //! //! Nothing here is anchored on an address, a symbol or a fixed offset into the image. A record is //! recognised by what the initialiser DOES — a 16-byte store of two plausible name strings, a //! description or nothing at `+0x10`, a small return type, and a `+0x40` that is either executable code //! or a small odd integer. A build that reshapes the descriptor fails those tests and produces nothing, //! which the release floor then catches. use crate::elf::CodeImage; use iced_x86::{Decoder, DecoderOptions, FlowControl, Instruction, Mnemonic, OpKind, Register}; use std::collections::{BTreeMap, HashMap}; /// The record stride, in bytes. Stated by the initialiser's own `idx*5 << 4`; kept as a constant only to /// validate what is decoded. /// /// `pub(crate)` because the LIVE class walk in `produce` steps the same records and must step them by /// the same number. Unlike the Pulse element stride this one is NOT derived by consensus — the /// initialiser states it in the instruction stream, so there is nothing to vote on — and a build that /// changes it shows up as decoded records failing validation here, not as a mis-strided live walk. pub(crate) const STRIDE: i64 = 80; /// Field displacements within the record. const F_NAME: i64 = 0x00; const F_CPP: i64 = 0x08; const F_DESC: i64 = 0x10; const F_RET: i64 = 0x18; const F_IMPL: i64 = 0x40; /// Longest accepted name/description, so a mis-decoded pointer into the middle of a blob cannot produce a /// megabyte "name". const MAX_NAME: usize = 128; const MAX_DESC: usize = 512; /// `ScriptDataType_t`, DERIVED by joining recovered bindings against Valve's own published VScript dump /// rather than assumed from Source's historical ordering. /// /// The distinction matters, and the first attempt at this table is the reason it is spelled out. Two /// anchors were available by inspection — a binding returning `float` stores `1`, one returning `int` /// stores `5` — and they fit Source 1's long-standing `FIELD_*` ordering, in which `5` is `BOOLEAN`. That /// reading was WRONG: joined against 389 bindings whose return type Valve states, `5` is `int` and `6` is /// `bool`. Two points are enough to fit a plausible table and not enough to check one. /// /// Agreement on the derived table is total where a comparison is meaningful. The apparent disagreements /// are Valve naming a SEMANTIC type over the same ABI type: `5` also covers `modifierpriority` and /// `UnitFilterResult` (enums, which are ints), and `31` also covers `CDOTA_BaseNPC` and `CBaseEntity` /// (entity handles, which are handles). /// /// The raw word ships beside the decoded name regardless — the rule `flags_raw` already follows — so a /// build that renumbers this can be re-read rather than silently mislabelled. const RET_TYPES: [(u16, &str); 13] = [ (0, "void"), (1, "float"), (3, "Vector"), (5, "int"), (6, "bool"), (13, "ehandle"), (14, "Vector"), (29, "unknown"), (30, "string"), (31, "handle"), (32, "table"), (37, "uint"), (39, "QAngle"), ]; /// Decode a return-type word, or `None` when the value is outside what is corroborated. pub fn ret_type_name(raw: u16) -> Option<&'static str> { RET_TYPES.iter().find(|(v, _)| *v == raw).map(|(_, n)| *n) } /// Where a binding's implementation lives, decoded from the pointer-to-member at `+0x40`. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Impl { /// A non-virtual member: the address itself. Addr(u64), /// A virtual member: `(pmf - 1) / 8` is the vtable slot index. Slot(u64), } /// One registered VScript binding. #[derive(Clone, Debug)] pub struct VScriptFunc { /// The script-facing name a Lua author calls (`TakeDamage`). pub name: String, /// The C++ binding name (`Script_TakeDamage`). Often but not always the script name with a prefix. pub cpp_name: String, /// Valve's own English description, where the registration supplies one. pub description: Option, /// The return type as stored, undecoded. pub ret_raw: u16, /// The return type decoded, or `None` if the value is outside the corroborated set. pub ret: Option<&'static str>, /// The implementation, as an address or a vtable slot. pub imp: Option, } /// What a register provably holds — CONSTANTS only, which is where this parts company with `concmd`'s /// tracker. /// /// There is no symbolic-base variant here and none is needed: a store is credited to a record through /// `recid`, propagated across `mov rD,rS`, rather than through a `(register, epoch)` pair. That is why /// the base survives being copied between registers, and why this tracker needs no epoch counter. #[derive(Clone, Copy, PartialEq, Eq, Debug)] enum V { Unknown, Const(u64), } impl V { fn konst(self) -> Option { match self { V::Const(c) => Some(c), V::Unknown => None, } } } /// The 64-bit parent register as a slot index. Thin wrapper over [`crate::abi::gp_slot`] — the mapping is a /// fixed SysV fact, and this file only narrows it to the `u8` its `[_; 16]` arrays index by. fn gpr(r: Register) -> Option { crate::abi::gp_slot(r).map(|s| s as u8) } fn xmm(r: Register) -> Option { r.is_xmm() .then(|| (r as usize - Register::XMM0 as usize) as u8) .filter(|i| *i < 16) } /// A field of a particular record: which record, and the displacement within it. type Slot = (u32, i64); /// Read a NUL-terminated string, rejecting anything that is not plausibly a name. fn text(img: &CodeImage, va: u64, max: usize) -> Option { let s = img.read_c_string(va)?; if s.is_empty() || s.len() > max { return None; } s.chars() .all(|c| c.is_ascii_graphic() || c == ' ') .then_some(s) } /// An identifier-shaped string — what a script-facing or C++ name must look like. Deliberately strict: /// a mis-decoded pointer usually lands on prose or a path, and both fail this. fn ident(img: &CodeImage, va: u64) -> Option { let s = text(img, va, MAX_NAME)?; let ok = s .chars() .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == ':') && s.chars() .next() .is_some_and(|c| c.is_ascii_alphabetic() || c == '_'); ok.then_some(s) } /// Decode the pointer-to-member at `+0x40`. /// /// The two forms are distinguished by the low bit, per the Itanium ABI. Both are validated: an address /// has to land in executable code, and a slot index has to be small enough to be a real vtable position. /// Anything else means the field is not what this reader thinks it is, and yields `None` rather than a /// confident wrong locator. fn decode_pmf(img: &CodeImage, pmf: u64) -> Option { if pmf == 0 { return None; } if pmf & 1 == 1 { let slot = (pmf - 1) / 8; // 2048 is the same ceiling `rtti` reads vtables to; past it this is not a slot index. (slot < 2048 && (pmf - 1).is_multiple_of(8)).then_some(Impl::Slot(slot)) } else { img.is_code(pmf).then_some(Impl::Addr(pmf)) } } /// Recover every VScript binding the image registers. /// /// One pass over the candidate functions, tracking what each register and XMM half provably holds and /// collecting stores to record-relative slots. A group of stores is accepted as a binding only if it /// presents the full shape, so partial or coincidental matches are dropped rather than guessed at. pub fn vscript_functions(img: &CodeImage) -> Vec { let entries = crate::locate::function_entries(img); let mut out: Vec = Vec::new(); let mut insn = Instruction::default(); for (i, &start) in entries.iter().enumerate() { let end = entries.get(i + 1).copied().unwrap_or(u64::MAX); let Some(code) = img.code_range(start, end) else { continue; }; let mut val = [V::Unknown; 16]; // Which registers currently hold a RECORD BASE, and which hold the half-built `idx*80` on the way // to one. This is the structural anchor: a store is only collected when its base was computed by // the initialiser's own `idx*5 << 4 + [class+0x28]`. Without it the pass collects any struct with // two string pointers at +0x00/+0x08, and `libserver` has at least one other table of that shape // (the network field serialisers) which then contributes records whose "name" is a netvar. let mut scaled = [false; 16]; // Which RECORD each register currently points at, not merely whether it points at one. Keying on // identity rather than on (register, epoch) is what lets a base survive `mov rcx,rax` — the // compiler routinely copies the base and then reuses the original for something else, writing // half a record through each. Keyed by register, those two halves land in different groups and // neither is complete. let mut recid: [Option; 16] = [None; 16]; let mut next_rec: u32 = 0; // Each XMM tracked as its two 64-bit halves, which is the only way the packed name store is // readable: both names reach the record through one 16-byte write. let mut xr = [(V::Unknown, V::Unknown); 16]; let mut stores: HashMap = HashMap::new(); let mut dec = Decoder::with_ip(64, code, start, DecoderOptions::NONE); while dec.can_decode() { dec.decode_out(&mut insn); if insn.flow_control() == FlowControl::Call { // Registers a call clobbers, so a value cannot survive one and be attributed to the wrong // record. Taken from `abi`, not transcribed as raw GPR indices — a second copy of a fixed // SysV fact is a copy that can drift. for c in crate::abi::caller_saved_slots() { val[c] = V::Unknown; scaled[c] = false; recid[c] = None; } xr = [(V::Unknown, V::Unknown); 16]; // MEASURED DEAD END, recorded so it is not re-attempted: treating `rax` as a speculative // record base after every call — on the theory that some registrations allocate a record // and fill it through the returned pointer — reintroduces precisely the network-field // serialisers the record-base anchor exists to reject (`CBaseEntity`/`m_fFlags`, // `CNetworkOriginCellCoordQuantizedVector`/`m_cellX`, the `*ChangedCompat` callbacks) and // recovers no additional binding. The structure built through a call's return here is the // CLASS descriptor, not a function record: its `+0x00`/`+0x08` hold the class name twice. continue; } match insn.mnemonic() { // `lea r,[rip+d]` — a string or global address. `lea rD,[rS+rS*4]` is something else // entirely: the first half of the record-base computation, `idx * 5`. Mnemonic::Lea => { if let Some(d) = gpr(insn.op0_register()) { let times_five = insn.memory_index() != Register::None && insn.memory_base() == insn.memory_index() && insn.memory_index_scale() == 4 && insn.memory_displacement64() == 0; val[d as usize] = if insn.is_ip_rel_memory_operand() { V::Const(insn.ip_rel_memory_address()) } else { V::Unknown }; scaled[d as usize] = times_five; recid[d as usize] = None; } } // `xor rD,rD` is the zeroing idiom, not an arithmetic unknown. It matters more here than // it looks: a `void` binding sets its return type with `xor r11d,r11d` and then stores // `r11w`, so treating this as an unknown loses every void-returning binding — which on // Dota is most of them. Mnemonic::Xor => { if let (Some(d), Some(s)) = (gpr(insn.op0_register()), gpr(insn.op1_register())) { val[d as usize] = if d == s { V::Const(0) } else { V::Unknown }; scaled[d as usize] = false; recid[d as usize] = None; } } // `shl rD,4` completes `idx * 80`. Any other shift of a scaled register means this is // not the idiom and the candidate is dropped. Mnemonic::Shl => { if let Some(d) = gpr(insn.op0_register()) { let keep = scaled[d as usize] && insn.op1_kind() == OpKind::Immediate8 && insn.immediate8() == 4; val[d as usize] = V::Unknown; scaled[d as usize] = keep; recid[d as usize] = None; } } // `add rD,[class+0x28]` turns `idx * 80` into the record's own address. From here every // store through `rD` is a field of one binding. Mnemonic::Add => { if let Some(d) = gpr(insn.op0_register()) { let base = scaled[d as usize] && insn.op1_kind() == OpKind::Memory; val[d as usize] = V::Unknown; scaled[d as usize] = false; recid[d as usize] = base.then(|| { next_rec += 1; next_rec }); } } // `movq xmm,[rip+slot]` loads a RELOCATED pointer — the script-facing name arrives this // way rather than as a `lea`, and reading it needs the relocation applied, which // `read_ptr` does. `movq xmm,r64` and the reverse also appear. Mnemonic::Movq | Mnemonic::Movd => { if let Some(x) = xmm(insn.op0_register()) { let lo = if insn.op1_kind() == OpKind::Memory { if insn.is_ip_rel_memory_operand() { img.read_ptr(insn.ip_rel_memory_address()) .map_or(V::Unknown, V::Const) } else { V::Unknown } } else if let Some(s) = gpr(insn.op1_register()) { val[s as usize] } else { V::Unknown }; // `movq` zeroes the upper half; that matters because the high name is inserted // afterwards and must not inherit a stale value. xr[x as usize] = (lo, V::Const(0)); } } // `pinsrq xmm,r64,1` — the second name packed into the high half. Mnemonic::Pinsrq => { if let (Some(x), Some(s)) = (xmm(insn.op0_register()), gpr(insn.op1_register())) && insn.op2_kind() == OpKind::Immediate8 { let v = val[s as usize]; if insn.immediate8() == 1 { xr[x as usize].1 = v; } else { xr[x as usize].0 = v; } } } // `movddup xmm,[rip+slot]` — ONE pointer written into both halves. This is the form the // compiler picks when the script-facing name and the C++ name are the SAME string, which // is the common case: only the bindings that need a distinct C++ name (usually a // `Script_`-prefixed wrapper) load two pointers. Missing this mnemonic costs roughly // four fifths of the registry on Dota, so it is not an edge case. Mnemonic::Movddup => { if let Some(x) = xmm(insn.op0_register()) { let v = if insn.is_ip_rel_memory_operand() { img.read_ptr(insn.ip_rel_memory_address()) .map_or(V::Unknown, V::Const) } else { V::Unknown }; xr[x as usize] = (v, v); } } // `punpcklqdq x0,x1` — the same pack, reached the other way. Mnemonic::Punpcklqdq => { if let (Some(a), Some(b)) = (xmm(insn.op0_register()), xmm(insn.op1_register())) { xr[a as usize] = (xr[a as usize].0, xr[b as usize].0); } } // The 16-byte store that lands both names. Mnemonic::Movups | Mnemonic::Movaps | Mnemonic::Movdqu | Mnemonic::Movdqa => { if insn.op0_kind() == OpKind::Memory && let Some(x) = xmm(insn.op1_register()) && let Some(b) = gpr(insn.memory_base()) && insn.memory_index() == Register::None && let Some(rec) = recid[b as usize] { let d = insn.memory_displacement64() as i64; if let Some(v) = xr[x as usize].0.konst() { stores.insert((rec, d), v); } if let Some(v) = xr[x as usize].1.konst() { stores.insert((rec, d + 8), v); } } } Mnemonic::Mov => { // Store to a record-relative slot: `mov [base+d], reg` or `mov [base+d], imm`. if insn.op0_kind() == OpKind::Memory && insn.memory_index() == Register::None && let Some(b) = gpr(insn.memory_base()) && let Some(rec) = recid[b as usize] { let d = insn.memory_displacement64() as i64; let v = match insn.op1_kind() { // A 16-bit store carries the return type. The tracker follows full // registers, so `mov [rec+0x18], r11w` reads back through `r11`. OpKind::Register => { gpr(insn.op1_register()).and_then(|s| val[s as usize].konst()) } OpKind::Immediate8 | OpKind::Immediate16 | OpKind::Immediate32 => { Some(insn.immediate32to64() as u64) } OpKind::Immediate32to64 | OpKind::Immediate8to64 => { Some(insn.immediate64()) } _ => None, }; if let Some(v) = v { stores.insert((rec, d), v); } continue; } // Register-to-register and immediate loads feed the tracker. if let Some(d) = gpr(insn.op0_register()) { // A plain `mov rD,rS` carries the RECORD IDENTITY across, not just the value. // This is the whole reason identity is tracked instead of a per-register flag: // the compiler routinely computes the base in one register, copies it to a // second, and then reuses the first — writing half the record through each. // Without this the second half is attributed to no record and the binding is // lost. `CBaseEntity::AddNewModifier` is the case that exposed it. recid[d as usize] = match insn.op1_kind() { OpKind::Register => { gpr(insn.op1_register()).and_then(|s| recid[s as usize]) } _ => None, }; scaled[d as usize] = false; val[d as usize] = match insn.op1_kind() { OpKind::Register => { gpr(insn.op1_register()).map_or(V::Unknown, |s| val[s as usize]) } OpKind::Immediate8 | OpKind::Immediate16 | OpKind::Immediate32 | OpKind::Immediate32to64 | OpKind::Immediate8to64 => V::Const(insn.immediate64()), _ => V::Unknown, }; } } _ => { // Any other write invalidates the destination, so a stale constant cannot be // attributed to a record it never reached. if let Some(d) = gpr(insn.op0_register()) { val[d as usize] = V::Unknown; scaled[d as usize] = false; recid[d as usize] = None; } if let Some(x) = xmm(insn.op0_register()) { xr[x as usize] = (V::Unknown, V::Unknown); } } } } // Group the collected stores by the record they were written to, then keep the groups that // present the full binding shape. // BTreeMap, and it is not a style choice: the emit order below decides which row survives the // `(name, cpp_name)` dedup at the end, so a HashMap made that a hash-order coin flip in a // byte-reproducible artifact. Record ids are minted in ascending address order by the `Add` arm, // so ordering by id is the natural reading order and changes nothing outside a tie. let mut groups: BTreeMap> = BTreeMap::new(); for ((rec, d), v) in stores { groups.entry(rec).or_default().insert(d, v); } for g in groups.values() { // The initialiser writes the name pair at the record's own `+0`, so a group without both is // not a binding — a partial match on some other structure, or a record whose construction // the tracker only saw half of. let (Some(&n), Some(&c)) = (g.get(&F_NAME), g.get(&F_CPP)) else { continue; }; let (Some(name), Some(cpp_name)) = (ident(img, n), ident(img, c)) else { continue; }; let ret_raw = g.get(&F_RET).copied().unwrap_or(u64::MAX); if ret_raw > u16::MAX as u64 { continue; } let ret_raw = ret_raw as u16; // The implementation is recorded when present but NOT required. It is written at the top of // the initialiser's next loop iteration, so whether it lands in this record's group depends // on which register the compiler happened to reuse — a binding whose fields are otherwise // complete must not be dropped over a scheduling accident. (The shared marshalling thunk at // `+0x38` is read past for the same reason and no longer recorded: nothing consumed it, and // the record-layout table in this module's header is where that offset is documented.) // // An earlier revision did require both, on the reasoning that `libserver` holds another table // of similar stride (the network field serialisers) whose records carry no code pointer. That // was treating a symptom: the record-base anchor above rejects those structurally, because // they are not built by `idx*5 << 4 + [class+0x28]`. Requiring the pair on top of that cost // real bindings — `CBaseEntity::EmitSound` among them — for no additional safety. out.push(VScriptFunc { name, cpp_name, description: g.get(&F_DESC).and_then(|&v| text(img, v, MAX_DESC)), ret_raw, ret: ret_type_name(ret_raw), imp: g.get(&F_IMPL).and_then(|&v| decode_pmf(img, v)), }); } } out.sort_by(|a, b| (&a.name, &a.cpp_name).cmp(&(&b.name, &b.cpp_name))); out.dedup_by(|a, b| a.name == b.name && a.cpp_name == b.cpp_name); out }