act on what the binary declares: callable Pulse shims, ConVars, string anchors; gen v2.1
All checks were successful
CI / lint (push) Successful in 17s
CI / fuzz (push) Successful in 2m6s
CI / test (push) Successful in 25s

This commit is contained in:
Kamal Tufekcic 2026-07-30 17:36:40 +03:00
commit 71ce34edd2
14 changed files with 1507 additions and 54 deletions

View file

@ -968,16 +968,19 @@ fn fold_valve_tables(
source_build: source_build.to_string(),
pulse: 0,
pulse_typed: 0,
pulse_callable: 0,
entity_inputs: 0,
entity_outputs: 0,
entity_classes: 0,
commands: 0,
convars: 0,
},
pulse: BTreeMap::new(),
entity_inputs: Vec::new(),
entity_outputs: Vec::new(),
entity_classes: BTreeMap::new(),
commands: Vec::new(),
convars: Vec::new(),
},
};
let (mut folded, mut ambiguous, mut unmakeable) = (0u32, 0usize, 0u32);
@ -1005,6 +1008,22 @@ fn fold_valve_tables(
// table-emptiness skip below.
let commands = concmd::console_commands(&img);
let lib = lib_name_from_file(f);
// ConVars: the other half of the console surface, read by the same pass. Documentation, not a
// locator — a consumer finds a convar by name at runtime; the flags are what it cannot get itself.
out.bindings
.convars
.extend(
concmd::convars(&img, &lib)
.into_iter()
.map(|c| model::ConVar {
name: c.name,
library: c.library,
description: c.description,
flags: c.flags,
flags_raw: c.flags_raw,
addr: c.addr,
}),
);
{
let addrs: Vec<u64> = commands.iter().map(|c| c.handler).collect();
let sigs = parallel_map(&addrs, threads, |&a| emit::make_sig(&img, a, sig_cap));
@ -1260,6 +1279,16 @@ fn fold_valve_tables(
returns: sig.as_ref().map(|s| s.returns.clone()).unwrap_or_default(),
typed: sig.is_some(),
descriptor: format!("{:#x}", b.descriptor),
shim: (b.shim != 0).then(|| format!("{:#x}", b.shim)),
// Measured per shim rather than assumed from the tier: the calling contract is fixed, but
// WHICH slots a given shim reads is the whole difference between host-callable and not.
call: (b.shim != 0)
.then(|| pulse::shim_reads(&img, b.shim))
.flatten()
.map(|r| model::ShimCall {
needs: r.needs().to_string(),
reads: r.reads.iter().map(|s| s.to_string()).collect(),
}),
};
// The registry is keyed by qualified name across libraries, so a binding registered by
// more than one module keeps ONE row. That is the documented lossiness — of the LIBRARY,
@ -1300,6 +1329,14 @@ fn fold_valve_tables(
.sort_by(|a, b| (&a.input, &a.handler, &a.addr).cmp(&(&b.input, &b.handler, &b.addr)));
out.bindings.meta.pulse = out.bindings.pulse.len();
out.bindings.meta.pulse_typed = out.bindings.pulse.values().filter(|b| b.typed).count();
// Counted from the emitted rows rather than tallied during the fold, so the number in `meta` cannot
// drift from the number of rows a consumer can actually act on.
out.bindings.meta.pulse_callable = out
.bindings
.pulse
.values()
.filter(|b| b.call.as_ref().is_some_and(|c| c.needs == "args-only"))
.count();
out.bindings.meta.entity_inputs = out.bindings.entity_inputs.len();
out.bindings
.entity_outputs
@ -1310,6 +1347,7 @@ fn fold_valve_tables(
.commands
.sort_by(|a, b| (&a.name, &a.addr).cmp(&(&b.name, &b.addr)));
out.bindings.meta.commands = out.bindings.commands.len();
out.bindings.meta.convars = out.bindings.convars.len();
if cmd_total > 0 {
let libs: BTreeSet<&str> = out
@ -1459,6 +1497,245 @@ fn binding_kind(f: valvetab::PulseFlags) -> model::BindingKind {
/// Assemble the monolith in memory and render its CS# gamedata (the string the live validate stage checks).
/// Writes NOTHING — `produce` holds the `Monolith` (to annotate it live) plus this render, and writes the
/// monolith exactly once at the end (after live validation, if a game is present).
/// Fold string anchors onto every monolith entry whose name carries one, in every tier.
///
/// Returns how many landed. That number is REPORTED rather than assumed because the two populations are
/// independent: the catalogue says which names have anchors, the derive says which names got a locator, and
/// an anchor for a name that never resolved has nowhere to go. A large gap is a fact about the build, not a
/// bug — but it should be visible rather than inferred from an artifact diff.
fn attach_anchors(mono: &mut model::Monolith, anchors: &BTreeMap<String, Vec<String>>) -> usize {
let mut n = 0;
for tier in [
&mut mono.core,
&mut mono.high_confidence,
&mut mono.experimental,
] {
for (name, e) in tier.iter_mut() {
if let Some(a) = anchors.get(name) {
// Deduplicated on the way in: the same anchor can appear on several catalogue variants,
// and this list ships in a byte-reproducible artifact.
for s in a {
if !e.locator.anchors.contains(s) {
e.locator.anchors.push(s.clone());
}
}
n += 1;
}
}
}
n
}
/// A string worth anchoring on: long enough to be distinctive, printable, and not a lone format specifier.
///
/// The thresholds are the knob this whole feature turns on. Loosening them raises coverage and lowers
/// distinctiveness; they were measured, not guessed — at these values 4,322 of libserver's 70,288 functions
/// have a unique anchor, and 27% of the shipped set does.
fn usable_anchor(s: &str) -> bool {
s.len() >= 8
&& s.len() <= 200
&& s.is_ascii()
&& s.chars().filter(|c| c.is_ascii_alphanumeric()).count() >= 5
}
/// Where every anchorable string in `img` is referenced FROM: string address -> the instruction addresses
/// that load it, plus the string itself.
///
/// Deliberately instruction-level and function-agnostic. Attributing a string to a function needs function
/// BOUNDARIES, and this binary does not reliably supply them — `.eh_frame_hdr` describes 8,327 of libserver's
/// ~70,000 functions, so a `[entry, next_entry)` range routinely spans a real function plus one or more
/// unindexed neighbours, and every neighbour's strings then look like the first function's. An instruction
/// address, by contrast, is exactly what it is. The caller decides membership against an extent it walked
/// itself, which is the only claim available that does not depend on the entry list being complete.
fn string_refs(img: &CodeImage) -> HashMap<u64, (String, Vec<u64>)> {
let mut entries = crate::locate::candidate_entries(img);
entries.extend(img.eh_frame_functions().into_iter().map(|(s, _)| s));
entries.sort_unstable();
entries.dedup();
let mut out: HashMap<u64, (String, Vec<u64>)> = HashMap::new();
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 insn = iced_x86::Instruction::default();
let mut dec = iced_x86::Decoder::with_ip(64, code, start, iced_x86::DecoderOptions::NONE);
while dec.can_decode() {
dec.decode_out(&mut insn);
if insn.is_invalid() || !insn.is_ip_rel_memory_operand() {
continue;
}
let va = insn.ip_rel_memory_address();
if let Some(e) = out.get_mut(&va) {
e.1.push(insn.ip());
} else if let Some(s) = img.read_c_string(va).filter(|s| usable_anchor(s)) {
out.insert(va, (s, vec![insn.ip()]));
}
}
}
out
}
/// Instruction addresses reachable from `entry` by following control flow, and the string addresses it loads.
///
/// The function's OWN extent, determined by where its branches go and where it returns, rather than by the
/// next symbol. That is what makes the anchor check sound without a complete function list.
fn reachable_strings(img: &CodeImage, entry: u64) -> Option<(HashSet<u64>, Vec<u64>)> {
const CAP: u64 = 0x4000;
let all = img.code_at(entry)?;
let extent = (all.len() as u64).min(CAP);
let code = &all[..extent as usize];
let mut seen: HashSet<u64> = HashSet::new();
let mut loads: Vec<u64> = Vec::new();
// Saturating for the same reason `pulse::shim_reads` is: a file-controlled extent must not wrap the
// range inside out under the overflow-checked build the fuzzers use.
let end = entry.saturating_add(extent);
let mut work = vec![entry];
let mut insn = iced_x86::Instruction::default();
while let Some(at) = work.pop() {
if at < entry || at >= end || !seen.insert(at) || seen.len() > 40000 {
continue;
}
let mut dec = iced_x86::Decoder::with_ip(
64,
&code[(at - entry) as usize..],
at,
iced_x86::DecoderOptions::NONE,
);
if !dec.can_decode() {
continue;
}
dec.decode_out(&mut insn);
if insn.is_invalid() || insn.len() == 0 {
continue;
}
if insn.is_ip_rel_memory_operand() {
loads.push(insn.ip_rel_memory_address());
}
match insn.flow_control() {
iced_x86::FlowControl::Return
| iced_x86::FlowControl::IndirectBranch
| iced_x86::FlowControl::Exception
| iced_x86::FlowControl::Interrupt => {}
iced_x86::FlowControl::UnconditionalBranch => work.push(insn.near_branch_target()),
iced_x86::FlowControl::ConditionalBranch => {
work.push(at + insn.len() as u64);
work.push(insn.near_branch_target());
}
_ => work.push(at + insn.len() as u64),
}
}
Some((seen, loads))
}
/// Derive an anchor for every entry that has none, from the address its SHIPPED signature resolves to.
///
/// Three conditions, each closing a way this can name the wrong function:
///
/// 1. **The resolved address must be a function ENTRY POINT.** ModSharp's `refs.strings` locates a
/// *function*; a great many shipped locators deliberately point MID-function (`CBaseButton::InputPress`
/// resolves to a `mov`, `BotNavIgnore` to a `je` — patterns anchored at a hook site, not a prologue). An
/// anchor cannot denote the same thing as one of those, so those entries get none rather than a locator
/// that resolves somewhere else.
/// 2. **The string must be referenced from inside the function's OWN flow-reachable code**, walked from the
/// entry, not from a `[entry, next_entry)` range. `.eh_frame_hdr` covers a small fraction of these
/// binaries' functions, so such a range routinely swallows unindexed neighbours and inherits their
/// strings — which is exactly how a first cut of this produced "`CBaseButton::InputPress` references
/// *Traced intervals in %.3fus*".
/// 3. **Every instruction that loads the string must be inside that same reachable set.** This is the
/// uniqueness test, done at instruction level so it never consults a function boundary. A string also
/// loaded from elsewhere locates nothing and is dropped.
///
/// Server-library only, the restriction [`recover_by_string_anchor`] already carries: the fold holds that one
/// image, and loading a second full set of 22 keyed images to reach the rest would add several hundred MB to a
/// pipeline that has already been OOM-killed on Dota.
///
/// Returns `(attached, considered)`. Degrades quietly toward FEWER anchors and never toward a wrong one.
fn attach_derived_anchors(
mono: &mut model::Monolith,
img: &CodeImage,
server_lib: &str,
) -> (usize, usize) {
let wants = |e: &model::MonoEntry| {
e.locator.anchors.is_empty()
&& e.locator
.signature
.as_ref()
.is_some_and(|s| s.library == server_lib)
};
let any = [&mono.core, &mono.high_confidence, &mono.experimental]
.iter()
.any(|t| t.values().any(&wants));
if !any {
return (0, 0);
}
let refs = string_refs(img);
// Condition 1's test set: the addresses this image treats as function starts.
let mut starts = crate::locate::candidate_entries(img);
starts.extend(img.eh_frame_functions().into_iter().map(|(s, _)| s));
starts.sort_unstable();
starts.dedup();
// Why each candidate was rejected, so a low yield is a FACT rather than a mystery. The three
// conditions fail for very different reasons and the mix differs sharply between games (CS2 derives
// ~5% of candidates, Dota ~0.5%); without this the difference is unattributable.
let (mut attached, mut considered) = (0usize, 0usize);
let (mut no_resolve, mut mid_fn, mut no_unique) = (0usize, 0usize, 0usize);
for tier in [
&mut mono.core,
&mut mono.high_confidence,
&mut mono.experimental,
] {
for e in tier.values_mut() {
if !wants(e) {
continue;
}
considered += 1;
let Some(sig) = e.locator.signature.as_ref() else {
continue;
};
let Ok(pat) = crate::sig::Pattern::parse(&sig.linux) else {
continue;
};
let hits = img.find(&pat);
let [addr] = hits.as_slice() else {
no_resolve += 1;
continue;
};
let addr = *addr;
if starts.binary_search(&addr).is_err() {
mid_fn += 1; // condition 1: mid-function locator, not a function an anchor can name
continue;
}
let Some((reach, loads)) = reachable_strings(img, addr) else {
continue;
};
// Candidates this function actually loads, longest first then lexicographic — deterministic,
// because this lands in a byte-reproducible artifact.
let mut cands: Vec<&(String, Vec<u64>)> = loads
.iter()
.filter_map(|va| refs.get(va))
.filter(|(_, from)| from.iter().all(|ip| reach.contains(ip)))
.collect();
cands.sort_unstable_by(|a, b| b.0.len().cmp(&a.0.len()).then_with(|| a.0.cmp(&b.0)));
match cands.first() {
Some((s, _)) => {
e.locator.anchors.push(s.clone());
attached += 1;
}
None => no_unique += 1,
}
}
}
eprintln!(
" anchor derivation: {attached} attached; rejected {no_resolve} (pattern did not resolve \
uniquely), {mid_fn} (locator is mid-function, which an anchor cannot name), {no_unique} (no string \
unique to the function)"
);
(attached, considered)
}
fn build_monolith(
prof: &GameProfile,
source_build: &str,
@ -1470,8 +1747,10 @@ fn build_monolith(
t3: &BTreeMap<String, model::Entry>,
prov: &BTreeMap<String, model::Provenance>,
abi: &BTreeMap<String, model::AbiShape>,
anchors: &BTreeMap<String, Vec<String>>,
server_img: Option<(&CodeImage, &str)>,
) -> Result<(model::Monolith, String)> {
let mono = assemble_monolith(
let mut mono = assemble_monolith(
prof,
source_build,
version,
@ -1483,6 +1762,22 @@ fn build_monolith(
prov,
abi,
)?;
// Attached AFTER assembly, across every tier at once, rather than at the three MonoEntry construction
// sites: an anchor belongs to a NAME, not to a tier, and one pass cannot leave a tier out by omission.
let attached = attach_anchors(&mut mono, anchors);
// Then DERIVE one for everything the catalogue does not cover. Reported separately from the catalogued
// count: they are different claims — one is a curated string somebody chose, the other is this build's
// own machine code answering the same question — and collapsing them would hide either going to zero.
let (derived, considered) = match server_img {
Some((img, lib)) => attach_derived_anchors(&mut mono, img, lib),
None => (0, 0),
};
eprintln!(
" string anchors: {attached} of {} catalogued reached the monolith; {derived} DERIVED for {considered} \
server entries that had none ({} total anchored)",
anchors.len(),
attached + derived
);
let cssharp = render::render_monolith_cssharp(&mono, model::TierSelect::HighConfidence);
eprintln!(
" monolith: {} core + {} high-conf + {} experimental + {} unresolved",
@ -1513,6 +1808,8 @@ pub(crate) struct FoldArgs<'a> {
pub unverified: &'a BTreeSet<String>,
/// The derive's measured argument footprints, folded onto the monolith entries.
pub abi: &'a BTreeMap<String, model::AbiShape>,
/// The derive's catalogue string anchors, folded onto the monolith entries the same way.
pub anchors: &'a BTreeMap<String, Vec<String>>,
pub sig_cap: usize,
pub version: &'a str,
pub full_names: Option<&'a Path>,
@ -1578,6 +1875,7 @@ pub(crate) fn build_gamedata_cmd(prof: &GameProfile, a: FoldArgs) -> Result<Fold
flagged,
unverified,
abi,
anchors,
sig_cap,
version,
full_names,
@ -1882,6 +2180,8 @@ pub(crate) fn build_gamedata_cmd(prof: &GameProfile, a: FoldArgs) -> Result<Fold
&t3,
&prov,
&abi_all,
anchors,
Some((&img, &default_lib)),
)?;
Ok(Folded {
mono,
@ -2144,7 +2444,6 @@ fn assemble_monolith(
name.clone(),
MonoEntry {
locator: render::entry_from_value(v),
class: None,
abi: abi.get(name.as_str()).cloned(),
provenance: Provenance {
source: Some(source.into()),
@ -2167,7 +2466,6 @@ fn assemble_monolith(
name.clone(),
MonoEntry {
locator,
class: None,
abi: abi.get(name.as_str()).cloned(),
provenance: provenance.clone(),
validated: None,
@ -2202,8 +2500,13 @@ fn assemble_monolith(
..Provenance::with_tier(g.tier)
};
exp_tier.entry(g.name.clone()).or_insert(MonoEntry {
locator: locator.clone(),
class: g.class.clone(),
// The class rides on the LOCATOR now, not beside it: for an offset entry the class is what
// makes the slot index mean anything, and keeping them together is what lets it reach the
// emitters through `Monolith::select`.
locator: model::Entry {
class: g.class.clone(),
..locator.clone()
},
// The band's own measurement first: it was taken at the guess's exact address, which IS
// what the locator resolves to. The name-keyed map is the fallback.
abi: g.abi.clone().or_else(|| abi.get(g.name.as_str()).cloned()),
@ -3486,6 +3789,13 @@ pub(crate) struct Derived {
/// Per-entry argument footprint measured in the target binary — the machine half of the
/// locator/prototype split, and what a declared prototype is checked against.
pub abi: BTreeMap<String, model::AbiShape>,
/// Per-entry string ANCHORS from the catalogue — distinctive literals the function references.
///
/// Its own side table for the same reason `abi` is: the derive→fold transport for `core` is the cssharp
/// locator shape, which has no anchor field, so anything ridden in on an `Entry` there would be dropped
/// when the fold re-parses it. Carrying them separately keeps the cssharp artifact unchanged — CS# has
/// no `refs` feature and should not grow a key it cannot read.
pub anchors: BTreeMap<String, Vec<String>>,
}
/// Derive a target build's gamedata OFFLINE and return it in memory.
@ -3710,11 +4020,22 @@ pub(crate) fn gamedata(
.collect();
flagged.extend(off_flag);
flagged.extend(abi_drift);
// Every catalogue entry's anchors, whether or not a byte sig located it. `recover_by_string_anchor`
// uses them only as a FALLBACK locator; this ships them as a supplement, which is a different job — an
// entry that resolved perfectly still benefits from a second locator with a different failure mode.
let anchors: BTreeMap<String, Vec<String>> = cat
.iter()
.filter_map(|f| {
let a: Vec<String> = string_anchors(f).into_iter().map(str::to_string).collect();
(!a.is_empty()).then(|| (f.name.clone(), a))
})
.collect();
Ok(Derived {
core,
flagged,
unverified: unverified.into_iter().collect(),
abi: abi_shapes,
anchors,
})
}
@ -3870,6 +4191,10 @@ fn derive_offsets(
match chain_and_vote(&anchors, hv, target_idx) {
Some((pred, conf)) if conf >= 80 => {
gd.set_offset(f.name.clone(), pred as i64);
// The class the slot was chained THROUGH — the only one that makes the index meaningful.
// Recorded here rather than reconstructed later from the name, which would be a different
// (and sometimes wrong) fact: a base-declared method sits in a derived class's vtable.
gd.set_class(f.name.clone(), f.class.clone());
off_ok += 1;
}
Some((pred, conf)) => {
@ -4506,7 +4831,6 @@ mod tests {
"CBaseEntity::TakeDamage".to_string(),
MonoEntry {
locator: Entry::signature("server", "48 8B 05 ? ? ? ?"),
class: None,
abi: None,
provenance: Provenance {
source: Some("catalogue".into()),
@ -4519,7 +4843,6 @@ mod tests {
"CCSPlayerPawn::IsBot".to_string(),
MonoEntry {
locator: Entry::offset(42),
class: None,
abi: None,
provenance: Provenance::with_tier(Tier::SelfNamed),
validated: None,
@ -4547,7 +4870,6 @@ mod tests {
use model::{Counts, Entry, MonoEntry, MonoMeta, Monolith, Provenance, Tier};
let entry = |loc: Entry, tier| MonoEntry {
locator: loc,
class: None,
abi: None,
provenance: Provenance::with_tier(tier),
validated: None,