read what the binary says about itself: names, signatures, prototypes; gen v2
All checks were successful
CI / lint (push) Successful in 17s
CI / fuzz (push) Successful in 1m52s
CI / test (push) Successful in 24s

This commit is contained in:
Kamal Tufekcic 2026-07-29 20:09:21 +03:00
commit c458b4cb50
34 changed files with 58363 additions and 192 deletions

View file

@ -1,5 +1,5 @@
//! CI orchestration + the LIVE half of the engine. `produce` runs the whole per-game build in one
//! long-running command (derive → fold → validate-live → sdk → fold-model), assembling the 3-file monolith
//! long-running command (derive → fold → validate-live → typed netvars → fold-model), assembling the monolith
//! artifact set; `classify-change` and `filter-corpus` are the CI *branch* primitives (is this buildid worth
//! a release? which corpus builds are code-distinct?). Everything that attaches to and drives a RUNNING
//! server lives here, not in `pipeline`: the semantic oracle (`run_live_oracle`, pawn probing, `fuzz_live_run`),
@ -10,9 +10,9 @@ use crate::elf::CodeImage;
use crate::locate::{find_file, load_lib};
use crate::par::{default_threads, parallel_map};
use crate::pipeline::{
ClassScope, CorpusModel, CorpusSource, FoldArgs, GdMap, annotate_validation, build_date,
build_gamedata_cmd, find_builds, fold_model_cmd, gamedata, label_of, lib_filename, load_model,
read_gamedata_str,
ClassScope, CorpusModel, CorpusSource, FoldArgs, Folded, GdMap, annotate_validation,
build_date, build_gamedata_cmd, find_builds, fold_model_cmd, gamedata, label_of, lib_filename,
load_model, read_gamedata_str,
};
use crate::profile::{self, GameProfile};
use crate::sig::Pattern;
@ -100,6 +100,12 @@ pub struct ProduceArgs<'a> {
pub full_names: Option<&'a Path>,
pub extra_offsets: Option<&'a Path>,
pub extra_sigs: Option<&'a Path>,
/// Declared prototypes to JUDGE against this build's measured footprints -> `abi-<game>.json`.
/// Static repo input, not rolling state, so it is passed as a path rather than fetched.
pub prototypes: Option<&'a Path>,
/// Valve's `PVAL_EHANDLE` entity-class naming (`mappings/ehandle-classes.json`) — a static repo
/// input the bindings artifact is enriched with. Optional.
pub ehandle_classes: Option<&'a Path>,
pub sig_cap: usize,
pub version: &'a str,
pub out_dir: &'a Path,
@ -130,6 +136,8 @@ pub fn produce_cmd(a: ProduceArgs) -> Result<()> {
full_names,
extra_offsets,
extra_sigs,
prototypes,
ehandle_classes,
sig_cap,
version,
out_dir,
@ -144,7 +152,7 @@ pub fn produce_cmd(a: ProduceArgs) -> Result<()> {
let p = |name: &str| out_dir.join(name);
let token = prof.token;
// Parse the corpus model ONCE (2.9 GB for Dota): the derive borrows it below, and the sidecar fold in
// Parse the corpus model ONCE (~571 MB for Dota): the derive borrows it below, and the sidecar fold in
// step 4 consumes the same instance — no second parse. A `--corpus` (genesis) run has no model (its model
// is distilled by `corpus-model`); a `--corpus-model` run rolls that model N to N+1 in the fold.
let cmodel: Option<CorpusModel> = match corpus_model {
@ -160,7 +168,11 @@ pub fn produce_cmd(a: ProduceArgs) -> Result<()> {
// 1. derive + fold (offline, in memory) -> the monolith + its CS# render (the string live validate checks)
eprintln!("\n===== derive + fold (offline) =====");
let derived = gamedata(prof, catalogue, source, target)?;
let (mut mono, cssharp) = build_gamedata_cmd(
let Folded {
mut mono,
cssharp,
bindings,
} = build_gamedata_cmd(
prof,
FoldArgs {
build,
@ -170,11 +182,13 @@ pub fn produce_cmd(a: ProduceArgs) -> Result<()> {
core: &derived.core,
flagged: &derived.flagged,
unverified: &derived.unverified,
abi: &derived.abi,
sig_cap,
version,
full_names,
extra_offsets,
extra_sigs,
ehandle_classes,
source_build: &label_of(target),
},
)?;
@ -213,6 +227,15 @@ pub fn produce_cmd(a: ProduceArgs) -> Result<()> {
typed_frac * 100.0,
NETVARS_MIN_TYPED * 100.0
);
// The enum table is read by shape like the class table, so a Valve reshape yields zero
// enums rather than wrong ones — safe, but silent. See GameProfile::min_schema_enums.
ensure!(
nv.meta.enums >= prof.min_schema_enums,
"recovered only {} schema enums (floor {}) — the SchemaSystem enum-binding layout \
likely moved; refusing to ship a schema with its enum vocabulary missing",
nv.meta.enums,
prof.min_schema_enums
);
netvars = Some(nv);
Ok(())
})();
@ -242,6 +265,57 @@ pub fn produce_cmd(a: ProduceArgs) -> Result<()> {
);
artifacts.push(nv_name);
}
// The declared callable surface. Gated PER TABLE, not on the sum: each is matched by its own record
// shape, so Valve reshaping one collapses that one alone — and a summed floor stays satisfied by the
// tables that still work. See GameProfile::min_pulse_bindings.
for (what, got, floor) in [
(
"Pulse bindings",
bindings.meta.pulse,
prof.min_pulse_bindings,
),
(
"typed Pulse signatures",
bindings.meta.pulse_typed,
prof.min_pulse_typed,
),
(
"entity-IO records",
bindings.meta.entity_inputs + bindings.meta.entity_outputs,
prof.min_entity_io,
),
(
"entity classnames",
bindings.meta.entity_classes,
prof.min_entity_classes,
),
(
"console commands",
bindings.meta.commands,
prof.min_commands,
),
] {
ensure!(
got >= floor,
"read only {got} {what} from Valve's in-binary tables (floor {floor}) — that table's layout \
likely moved; refusing to ship a release whose declared surface silently collapsed"
);
}
if !bindings.is_empty() {
let bd_name = format!("bindings-{token}.json");
std::fs::write(p(&bd_name), serde_json::to_string_pretty(&bindings)?)
.with_context(|| format!("write {bd_name}"))?;
eprintln!(
" binding registry -> {bd_name}: {} Pulse bindings ({} typed), {} entity-IO inputs, {} outputs, {} entity classnames, {} console commands",
bindings.meta.pulse,
bindings.meta.pulse_typed,
bindings.meta.entity_inputs,
bindings.meta.entity_outputs,
bindings.meta.entity_classes,
bindings.meta.commands
);
artifacts.push(bd_name);
}
// 4. sidecar: fold model N -> N+1 (offline), emitted when a --corpus-model was the source. The derive has
// returned, so its read-only borrow of the model is done — the fold consumes the same instance by value.
@ -252,6 +326,28 @@ pub fn produce_cmd(a: ProduceArgs) -> Result<()> {
artifacts.push(model_name);
}
// The prototype manifest: the declared parameter types, each judged against the footprint measured
// in THIS build. Emitted beside the gamedata because the two answer different questions — where a
// function is, and how to call it — and a consumer needs both to make a call at all.
if let Some(pp) = prototypes {
let man = crate::prototypes::build_manifest(pp, &mono, netvars.as_ref().map(|n| &n.types))?;
let ab_name = format!("abi-{token}.json");
std::fs::write(p(&ab_name), serde_json::to_string_pretty(&man)?)
.with_context(|| format!("write {ab_name}"))?;
let n = |k: &str| man.meta.counts.get(k).copied().unwrap_or(0);
eprintln!(
" prototype manifest -> {ab_name}: {} entries ({} verified, {} mismatch, {} unverified, \
{} return-only, {} ambiguous)",
man.functions.len(),
n("status:verified"),
n("status:mismatch"),
n("status:unverified"),
n("status:return-only"),
n("core:overloaded") + n("high_confidence:overloaded")
);
artifacts.push(ab_name);
}
// 5. the interop manifest.
let manifest = json!({ "version": version, "artifacts": artifacts });
std::fs::write(p("manifest.json"), serde_json::to_string_pretty(&manifest)?)?;
@ -758,7 +854,7 @@ const ORACLE_MIN_SAMPLE: u32 = 25;
/// wholesale type-record reshape, not on the odd unresolved field.
const NETVARS_MIN_TYPED: f64 = 0.5;
/// The live-fuzzing loop against an ALREADY-ATTACHED server — shared by the standalone `fuzz-live`
/// The live-fuzzing loop against an ALREADY-ATTACHED server — shared by the standalone
/// command and the `integration-test` harness (which owns the server, so no separate launch and no fixed
/// wall-clock: it runs exactly `iterations` probes and stops).
fn fuzz_live_run(
@ -1018,6 +1114,10 @@ pub(crate) fn run_live_oracle(
_ => None,
};
// The derived gamedata, parsed ONCE: the CALL test below needs THIS build's IsPlayerPawn slot, and
// the validate stage needs the whole document.
let doc = gamedata.map(read_gamedata_str).transpose()?;
eprintln!("\n=== read-only oracle on the owned process ===");
let mut verdicts: Vec<(&str, OracleCounts)> = Vec::new();
verdicts.push(("schema-layout", verify_live_cmd(prof, pid, build, lib)?));
@ -1036,12 +1136,37 @@ pub(crate) fn run_live_oracle(
// not `?`-propagate past produce's fail-fast and abort the release. Same treatment as
// `callable_method_sweep` below. `(|| -> Option ...)()` lets one unreadable access bail the probe.
println!("\n=== CALL test (ptrace injection — the thing read-only can't do) ===");
let is_player_pawn = pa.is_player_pawn_slot;
// The slot THIS build derived, not the constant frozen in the profile. The two agree today, but
// the catalogue shows this slot taking four distinct values in nine months, and a stale index
// does not fail loudly — it ptrace-CALLS whatever function now occupies it, on the same live
// process this run then reads typed netvars from and fuzzes 500 times. The frozen value survives
// only as a fallback for a run with no rendered gamedata to consult.
let is_player_pawn = doc
.as_ref()
.and_then(|d| d.get("CBaseEntity::IsPlayerPawn"))
.and_then(|e| render::entry_from_value(e).offset)
.and_then(|o| u64::try_from(o).ok())
.unwrap_or(pa.is_player_pawn_slot);
if is_player_pawn != pa.is_player_pawn_slot {
eprintln!(
" NOTE derived IsPlayerPawn slot {is_player_pawn} differs from the profile's frozen \
{} using the derived one; update GameProfile::is_player_pawn_slot",
pa.is_player_pawn_slot
);
}
let probed = (|| -> Option<()> {
let hp = live.read_i32(pawn + health).ok()?;
println!("alive pawn {pawn:#014x}, live m_iHealth = {hp}");
let vtable_ptr = live.read_u64(pawn).ok()?;
let func = live.read_u64(vtable_ptr + is_player_pawn * 8).ok()?;
// Same gate the other two `call_remote` sites apply: never inject a call to something that
// is not executable code in the live process.
if !live.is_exec(func) {
println!(
" slot {is_player_pawn} does not point at live executable code — skipping"
);
return None;
}
println!(
"calling IsPlayerPawn (gamedata vtable offset {is_player_pawn}, fn {func:#x}) on the live pawn..."
);
@ -1067,11 +1192,11 @@ pub(crate) fn run_live_oracle(
}
}
let live_result = if let Some(gd) = gamedata {
let live_result = if gamedata.is_some() {
println!("\n=== validate-live: derived gamedata vs the running server ===");
// Parse the monolith's CS# render (passed in-memory, no `gamedata.json`) once — it feeds sig/offset
// validation AND the pawn sweep/fuzz below.
let doc = read_gamedata_str(gd)?;
// Parsed once, above — it feeds the CALL test's slot, sig/offset validation, and the pawn
// sweep/fuzz below.
let doc = doc.expect("parsed above whenever `gamedata` is Some");
let (kept, entry_verdicts) = validate_live_cmd(prof, pid, build, &doc)?;
// The semantic sweep + live fuzz operate on a live pawn; pawn-less games stop at sig validation.
if let Some(PawnContext {
@ -1154,17 +1279,23 @@ pub(crate) fn launch_bots_server(
bots: u32,
) -> Result<OwnedServer> {
let bindir = game.join("bin/linuxsteamrt64");
let exe = bindir.join(prof.executable);
ensure!(
exe.exists(),
bindir.join(prof.executable).exists(),
"{} server executable `{}` not found at {}",
prof.display_name,
prof.executable,
exe.display()
bindir.join(prof.executable).display()
);
// ABSOLUTE from here on. `current_dir` below is applied in the CHILD before `exec`, so a relative
// `--game-dir` would have the program path re-resolved from inside `bindir` and fail to spawn —
// after the check above had just found the file, which is the worst shape for a guard to have.
let bindir = bindir
.canonicalize()
.with_context(|| format!("resolve {}", bindir.display()))?;
let exe = bindir.join(prof.executable);
// Live-oracle readiness anchor (via the shared resolve_ready_anchor). A game with
// a player pawn waits for an ALIVE pawn; a pawn-less game (Dota) waits for a live gamerules proxy = map
// loaded + libserver ready, which is all produce's live stages (validate-live + sdk) need.
// loaded + libserver ready, which is all produce's live stages (validate-live + typed netvars) need.
let img = load_lib(build, lib)?;
let (ready_vt, pawn_health) = resolve_ready_anchor(prof, &img)?;
let logpath = std::env::temp_dir().join(format!("{}-produce.log", prof.token));