source2rosetta/src/vscript.rs
Kamal Tufekcic 22ab973f0c
All checks were successful
CI / lint (push) Successful in 16s
CI / fuzz (push) Successful in 2m3s
CI / test (push) Successful in 25s
minor tweaks
2026-08-03 03:59:53 +03:00

558 lines
28 KiB
Rust

//! 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<String>,
/// 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<Impl>,
}
/// 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<u64> {
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<u8> {
crate::abi::gp_slot(r).map(|s| s as u8)
}
fn xmm(r: Register) -> Option<u8> {
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<String> {
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<String> {
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<Impl> {
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<VScriptFunc> {
let entries = crate::locate::function_entries(img);
let mut out: Vec<VScriptFunc> = 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<u32>; 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<Slot, u64> = 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<u32, HashMap<i64, u64>> = 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
}