2644 lines
117 KiB
Rust
2644 lines
117 KiB
Rust
//! CI orchestration + the LIVE half of the engine. `produce` runs the whole per-game build in one
|
||
//! long-running command (derive → fold → validate-live → typed netvars → fold-model), merging every
|
||
//! stage's account of a function into the one shipped artifact; `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`),
|
||
//! `integration_test_cmd`, the live validators (`validate_live_cmd`/`verify_live_cmd` + their sig/offset
|
||
//! checks), and `launch_bots_server`. `pipeline` stays the pure OFFLINE derivation engine these consume.
|
||
|
||
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, 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;
|
||
use crate::taxonomy::{class_of, is_query_method};
|
||
use crate::{abi, emit, live, model, render, rtti, schema};
|
||
use anyhow::{Context, Result, bail, ensure};
|
||
use serde_json::{Value, json};
|
||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||
use std::path::{Path, PathBuf};
|
||
|
||
/// The per-game derive INPUTS a `--seed` bundle carries, whether passed as loose files or unpacked from
|
||
/// the bundle: the catalogue plus the naming-harvest sections, and a folded snapshot of the contributions
|
||
/// inbox.
|
||
///
|
||
/// NOT the complete input surface, and the difference matters when tracking down where a fact came from.
|
||
/// The judged/authored inputs stay separate flags (`--prototypes`, `--semantics`, `--ehandle-classes`),
|
||
/// the corpus model is its own streamed-write artifact (`--corpus-model`), and on the loose-flag path the
|
||
/// derive additionally reads `<catalogue.parent()>/contributions/<game_key>/*.json` implicitly — which is
|
||
/// why `unpack_seed` writes the bundle's contributions beside the catalogue rather than anywhere else.
|
||
pub struct SeedInputs {
|
||
pub catalogue: PathBuf,
|
||
/// Optional bring-up refinements — a seed without them still derives the full catalogue.
|
||
pub promotable: Option<PathBuf>,
|
||
pub candidates: Option<PathBuf>,
|
||
pub full_names: Option<PathBuf>,
|
||
pub extra_offsets: Option<PathBuf>,
|
||
pub extra_sigs: Option<PathBuf>,
|
||
}
|
||
|
||
/// Un-bundle a `--seed` file — one JSON object whose sections are the verbatim contents of the former
|
||
/// loose files — into on-disk inputs under `work/`, so the derive reads them exactly as before (the fold
|
||
/// re-parses, so a serde round-trip of a section changes nothing downstream). Only `catalogue` is required;
|
||
/// every other section is optional and a missing one stays `None` — a game with no naming harvest yet ships
|
||
/// a seed with no `promotable`, and `seed-dota2.json` carries no `contributions`.
|
||
pub fn unpack_seed(prof: &GameProfile, seed: &Path, work: &Path) -> Result<SeedInputs> {
|
||
let v: Value = serde_json::from_str(&std::fs::read_to_string(seed)?)
|
||
.with_context(|| format!("parse seed {}", seed.display()))?;
|
||
std::fs::create_dir_all(work)?;
|
||
let dump = |key: &str, fname: &str| -> Result<Option<PathBuf>> {
|
||
match v.get(key) {
|
||
Some(section) if !section.is_null() => {
|
||
let p = work.join(fname);
|
||
std::fs::write(&p, serde_json::to_string(section)?)?;
|
||
Ok(Some(p))
|
||
}
|
||
_ => Ok(None),
|
||
}
|
||
};
|
||
// Contributions unpack BESIDE the catalogue (work/contributions/<game_key>/), so the derive's
|
||
// `load_contributions` (which reads catalogue.parent()) folds them exactly as the loose form does from
|
||
// mappings/contributions/. The repo dir stays the human PR inbox; the seed carries a folded snapshot.
|
||
let cdir = work.join("contributions").join(prof.game_key);
|
||
// CLEARED first, and it has to be: `load_contributions` folds every file it finds in this directory,
|
||
// so a leftover from an earlier run under a reused --out-dir would be merged into a build whose seed
|
||
// never mentioned it. The artifact would then depend on what happened to be on disk rather than on
|
||
// its inputs, which is the one property a reproducible derive cannot give up.
|
||
if cdir.exists() {
|
||
std::fs::remove_dir_all(&cdir)
|
||
.with_context(|| format!("clear stale contributions in {}", cdir.display()))?;
|
||
}
|
||
if let Some(Value::Object(files)) = v.get("contributions") {
|
||
std::fs::create_dir_all(&cdir)?;
|
||
for (fname, content) in files {
|
||
std::fs::write(cdir.join(fname), serde_json::to_string(content)?)?;
|
||
}
|
||
}
|
||
let need = |o: Option<PathBuf>, key: &str| {
|
||
o.with_context(|| format!("seed missing required '{key}' section"))
|
||
};
|
||
Ok(SeedInputs {
|
||
catalogue: need(dump("catalogue", "needed-functions.json")?, "catalogue")?,
|
||
promotable: dump("promotable", "promotable.json")?,
|
||
candidates: dump("candidates", "candidates.json")?,
|
||
full_names: dump("full_names", "full-names.json")?,
|
||
extra_offsets: dump("extra_offsets", "extra-offsets.json")?,
|
||
extra_sigs: dump("extra_sigs", "extra-sigs.json")?,
|
||
})
|
||
}
|
||
|
||
/// Read the authored function descriptions. A repo input, so a parse failure is a hard error rather
|
||
/// than a silently empty join — an unreadable file and a file describing nothing are the same size in
|
||
/// the output otherwise.
|
||
fn read_semantics(path: &Path) -> Result<BTreeMap<String, model::Description>> {
|
||
#[derive(serde::Deserialize)]
|
||
struct SemanticsDoc {
|
||
descriptions: BTreeMap<String, model::Description>,
|
||
}
|
||
let text = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
|
||
let doc: SemanticsDoc =
|
||
serde_json::from_str(&text).with_context(|| format!("parse {}", path.display()))?;
|
||
Ok(doc.descriptions)
|
||
}
|
||
|
||
/// Inputs to [`produce_cmd`], grouped so the CLI passes ONE named-field value instead of a long positional
|
||
/// list (where two same-typed `Option<&Path>` could silently transpose). Built by the clap front-end after
|
||
/// it resolves the corpus source and the seed-or-loose derive inputs.
|
||
pub struct ProduceArgs<'a> {
|
||
pub prof: &'a GameProfile,
|
||
/// A launchable game install. `Some` → the full build (validate-live + typed netvars); `None` → OFFLINE
|
||
/// (no server, so no live validation and a `null` schema). The whole offline/full switch — there
|
||
/// is no separate flag.
|
||
pub game: Option<&'a Path>,
|
||
pub build: Option<&'a Path>,
|
||
pub lib: &'a str,
|
||
pub catalogue: &'a Path,
|
||
/// Reference-signal source paths — exactly one is `Some`, enforced at the top of `produce_cmd` rather
|
||
/// than only by clap, because an embedder calling `produce_cmd` directly bypasses the CLI. A
|
||
/// `--corpus-model` is parsed ONCE here so the derive can borrow it and the sidecar fold can consume
|
||
/// the same instance.
|
||
pub corpus: Option<&'a Path>,
|
||
pub corpus_model: Option<&'a Path>,
|
||
/// Class scope for the sidecar model fold — must match the scope the input model was distilled with.
|
||
pub class_scope: ClassScope,
|
||
pub target: &'a Path,
|
||
pub promotable: Option<&'a Path>,
|
||
pub candidates: Option<&'a Path>,
|
||
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. Static repo input, not
|
||
/// rolling state, so it is passed as a path rather than fetched.
|
||
pub prototypes: Option<&'a Path>,
|
||
/// Authored function descriptions (`mappings/semantics-<game>.json`), folded in beside each
|
||
/// function. A repo input on the same terms as `prototypes` — reviewed, committed, and keyed on the
|
||
/// NAME, so it survives every build that does not rename a function. Optional.
|
||
pub semantics: Option<&'a Path>,
|
||
/// Valve's `PVAL_EHANDLE` entity-class naming (`mappings/ehandle-classes.json`) — a static repo
|
||
/// input the Pulse surface is enriched with. Optional.
|
||
pub ehandle_classes: Option<&'a Path>,
|
||
pub sig_cap: usize,
|
||
pub version: &'a str,
|
||
pub out_dir: &'a Path,
|
||
pub wait: u64,
|
||
pub map: &'a str,
|
||
pub bots: u32,
|
||
}
|
||
|
||
/// The whole per-game build in ONE command, entirely in memory — derive → fold → (if a game is given)
|
||
/// validate-live + typed netvars → judge prototypes → merge → fold model — writing the release set ONCE
|
||
/// (`rosetta-<game>.json` + `model-<game>.json` [corpus-model source] + `manifest.json`). No per-stage
|
||
/// intermediate files: the derive hands its rendered gamedata straight to the fold, the fold's catalogue
|
||
/// is annotated live in place, the schema is read straight off the process, and the merge folds all of
|
||
/// it together. Offline vs full is decided solely by whether a launchable `game` is present.
|
||
pub fn produce_cmd(a: ProduceArgs) -> Result<()> {
|
||
let ProduceArgs {
|
||
prof,
|
||
game,
|
||
build,
|
||
lib,
|
||
catalogue,
|
||
corpus,
|
||
corpus_model,
|
||
class_scope,
|
||
target,
|
||
promotable,
|
||
candidates,
|
||
full_names,
|
||
extra_offsets,
|
||
extra_sigs,
|
||
prototypes,
|
||
semantics,
|
||
ehandle_classes,
|
||
sig_cap,
|
||
version,
|
||
out_dir,
|
||
wait,
|
||
map,
|
||
bots,
|
||
} = a;
|
||
// Exactly one reference-signal source, checked HERE rather than only in clap: an embedder calls
|
||
// `produce_cmd` directly, and the `(Some(m), _)` arm below silently drops `--corpus` when both are
|
||
// given — a derive that quietly used a different signal source than the one the caller named.
|
||
ensure!(
|
||
corpus.is_some() != corpus_model.is_some(),
|
||
"pass exactly ONE of --corpus <binaries> / --corpus-model <model.json> — they are two different \
|
||
reference-signal sources and only one can be used"
|
||
);
|
||
// `build` (the on-disk libs for make-sig + live validation) defaults to the game when one is given (so
|
||
// the validated libs match the running server), else the derive target.
|
||
let build = build.unwrap_or_else(|| game.unwrap_or(target));
|
||
std::fs::create_dir_all(out_dir)?;
|
||
let p = |name: &str| out_dir.join(name);
|
||
let token = prof.token;
|
||
|
||
// 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 {
|
||
Some(m) => Some(load_model(m)?),
|
||
None => None,
|
||
};
|
||
// The `ensure!` above has already ruled out both-or-neither.
|
||
let source = match (&cmodel, corpus) {
|
||
(Some(m), _) => CorpusSource::Model(m),
|
||
(None, Some(c)) => CorpusSource::Binaries(c),
|
||
(None, None) => bail!("pass --corpus <binaries> or --corpus-model <model.json>"),
|
||
};
|
||
|
||
// 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 Folded {
|
||
mut mono,
|
||
cssharp,
|
||
mut bindings,
|
||
} = build_gamedata_cmd(
|
||
prof,
|
||
FoldArgs {
|
||
build,
|
||
lib,
|
||
promotable,
|
||
candidates,
|
||
core: &derived.core,
|
||
flagged: &derived.flagged,
|
||
unverified: &derived.unverified,
|
||
abi: &derived.abi,
|
||
anchors: &derived.anchors,
|
||
sig_cap,
|
||
version,
|
||
full_names,
|
||
extra_offsets,
|
||
extra_sigs,
|
||
ehandle_classes,
|
||
source_build: &label_of(target),
|
||
},
|
||
)?;
|
||
|
||
// The DERIVED-SURFACE collapse floor, checked here rather than left to the live oracle — and it has to
|
||
// be, because that gate is a pass RATE over entries that reached the gamedata document. A signature
|
||
// that failed to resolve never enters it, so a derive emitting forty functions instead of four
|
||
// thousand passes at 100%. Every in-binary table already has one of these; the tool's primary product
|
||
// did not. See `GameProfile::min_core_functions`.
|
||
let shipped = mono.meta.counts.core + mono.meta.counts.high_confidence;
|
||
ensure!(
|
||
shipped >= prof.min_core_functions,
|
||
"derived only {shipped} shipped functions ({} core + {} high-confidence, floor {}) — a corpus \
|
||
model from another game or branch, a --target from the wrong build, or a library missing from \
|
||
the tree all land here; refusing to publish a collapsed derive",
|
||
mono.meta.counts.core,
|
||
mono.meta.counts.high_confidence,
|
||
prof.min_core_functions
|
||
);
|
||
|
||
// 2. live stages — ONLY when a launchable game is given: validate (annotate the monolith in place) + the
|
||
// typed netvars (read straight off the live process). produce launches + tears down its own server.
|
||
let mut netvars: Option<model::Schema> = None;
|
||
match game {
|
||
Some(game) => {
|
||
eprintln!("\n===== live: validate + typed netvars =====");
|
||
let mut server = launch_bots_server(prof, game, build, lib, wait, map, bots)?;
|
||
let pid = server.pid;
|
||
let result = (|| -> Result<()> {
|
||
if let Some((_, verdicts)) =
|
||
run_live_oracle(prof, pid, build, lib, Some(&cssharp), 500)?
|
||
{
|
||
annotate_validation(&mut mono, &verdicts);
|
||
}
|
||
// The one VScript field the fold cannot derive. Done here rather than inside the oracle
|
||
// because it ENRICHES the artifact instead of verifying a claim about it, and because
|
||
// `integration-test` — which shares the oracle — builds no artifact to enrich.
|
||
if !bindings.vscript.is_empty() {
|
||
let img = load_lib(build, lib)?;
|
||
let live = live::LiveProcess::attach(pid)?;
|
||
// `lib` is already the mapped filename (`libserver.so`), so it is passed through
|
||
// rather than rebuilt. An earlier revision spelled it `lib{lib}.so`, produced
|
||
// `liblibserver.so.so`, and the `if let Some` swallowed the miss — a silent zero that
|
||
// looks exactly like "this build has no classes to attribute". Hence the else.
|
||
match live.base(lib) {
|
||
Some(base) => {
|
||
let (set, classes) =
|
||
attribute_vscript_classes(&live, base, &img, &mut bindings);
|
||
eprintln!(
|
||
" VScript classes (live-only): {set} of {} bindings attributed across \
|
||
{classes} classes",
|
||
bindings.vscript.len()
|
||
);
|
||
}
|
||
None => eprintln!(
|
||
" WARNING: {lib} is not mapped in the live server, so no VScript class \
|
||
could be attributed — the artifact will ship without the field the \
|
||
`moddota` format groups by."
|
||
),
|
||
}
|
||
// Live-only, so it is floored HERE rather than beside the other binding floors below:
|
||
// those run on every build, and zero is the correct offline answer. See
|
||
// GameProfile::min_vscript_classed.
|
||
ensure!(
|
||
bindings.meta.vscript_classed >= prof.min_vscript_classed,
|
||
"attributed only {} VScript bindings to an owning class (floor {}) — the live \
|
||
descriptor walk likely broke; refusing to ship a binding registry the `moddota` \
|
||
format would render as empty",
|
||
bindings.meta.vscript_classed,
|
||
prof.min_vscript_classed
|
||
);
|
||
}
|
||
let nv = schema::live_schema(prof, pid, build, &label_of(target))?;
|
||
// Gate the type-resolution the schema-layout oracle can't see (see NETVARS_MIN_TYPED): a
|
||
// wholesale CSchemaType reshape resolves every field 'untyped' and would otherwise ship a
|
||
// typeless netvars file at exit 0. Big denominator (thousands of fields), so ORACLE_MIN_SAMPLE
|
||
// is always met; this is purely the all-untyped tripwire.
|
||
let fields = nv.meta.typed + nv.meta.untyped;
|
||
let typed_frac = if fields == 0 {
|
||
1.0
|
||
} else {
|
||
nv.meta.typed as f64 / fields as f64
|
||
};
|
||
ensure!(
|
||
fields < ORACLE_MIN_SAMPLE as usize || typed_frac >= NETVARS_MIN_TYPED,
|
||
"typed netvars resolved only {}/{} fields ({:.1}%, floor {:.0}%) — the runtime \
|
||
CSchemaType layout likely moved; refusing to ship a typeless schema",
|
||
nv.meta.typed,
|
||
fields,
|
||
typed_frac * 100.0,
|
||
NETVARS_MIN_TYPED * 100.0
|
||
);
|
||
// The CLASS table first, because everything below rests on it — and because it is the
|
||
// one table whose collapse no other check here would catch. See
|
||
// GameProfile::min_schema_classes for why the live oracle's own class gate does not.
|
||
ensure!(
|
||
nv.classes.len() >= prof.min_schema_classes,
|
||
"recovered only {} schema classes (floor {}) — the SchemaClassInfoData_t layout \
|
||
likely moved; refusing to ship a schema whose class table collapsed",
|
||
nv.classes.len(),
|
||
prof.min_schema_classes
|
||
);
|
||
// 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
|
||
);
|
||
eprintln!(
|
||
" typed netvars: {} classes, {} typed fields, {} untyped",
|
||
nv.classes.len(),
|
||
nv.meta.typed,
|
||
nv.meta.untyped
|
||
);
|
||
netvars = Some(nv);
|
||
Ok(())
|
||
})();
|
||
let _ = server.child.kill();
|
||
let _ = server.child.wait();
|
||
result?; // tear the server down first, THEN surface any stage error
|
||
}
|
||
None => {
|
||
eprintln!("\n===== offline: no --game-dir -> no live validate, no typed schema =====")
|
||
}
|
||
}
|
||
|
||
// 3. 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,
|
||
),
|
||
(
|
||
"host-callable Pulse shims",
|
||
bindings.meta.pulse_callable,
|
||
prof.min_pulse_callable,
|
||
),
|
||
(
|
||
"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,
|
||
),
|
||
("ConVars", bindings.meta.convars, prof.min_convars),
|
||
("VScript bindings", bindings.meta.vscript, prof.min_vscript),
|
||
] {
|
||
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"
|
||
);
|
||
}
|
||
// 4. the prototype manifest — the declared parameter types, each judged against the footprint measured
|
||
// in THIS build. Built before the merge rather than written beside it: a declaration and the
|
||
// measurement that judges it are two facts about one function, and the merged record holds both.
|
||
let manifest_abi = match prototypes {
|
||
Some(pp) => {
|
||
// The script VM's declared return types, keyed by the C++ name the fold used for the locator,
|
||
// so a binding's `void` reaches the manifest instead of the register class measurement infers.
|
||
let vs_ret: BTreeMap<String, String> = bindings
|
||
.vscript
|
||
.iter()
|
||
.filter_map(|v| v.ret.as_ref().map(|r| (v.cpp.clone(), r.clone())))
|
||
.collect();
|
||
let man = crate::prototypes::build_manifest(
|
||
pp,
|
||
&mono,
|
||
netvars.as_ref().map(|n| &n.types),
|
||
Some(&vs_ret),
|
||
)?;
|
||
let n = |k: &str| man.meta.counts.get(k).copied().unwrap_or(0);
|
||
eprintln!(
|
||
" prototypes: {} judged ({} 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")
|
||
);
|
||
Some(man)
|
||
}
|
||
None => None,
|
||
};
|
||
|
||
// 5. authored descriptions — a repo input like the prototypes, keyed on the NAME. A name this build
|
||
// does not ship simply finds no record; the join count below is what makes that visible.
|
||
let descriptions = match semantics {
|
||
Some(sp) => read_semantics(sp)?,
|
||
None => BTreeMap::new(),
|
||
};
|
||
|
||
// 6. merge and write the release artifact ONCE. Everything above is a different STAGE's account of
|
||
// the same functions, so it ships as one record per function rather than four files to join.
|
||
let rosetta = model::merge(mono, manifest_abi, Some(bindings), netvars, descriptions);
|
||
let art_name = format!("rosetta-{token}.json");
|
||
std::fs::write(p(&art_name), serde_json::to_string_pretty(&rosetta)?)
|
||
.with_context(|| format!("write {art_name}"))?;
|
||
let j = &rosetta.meta.joined;
|
||
eprintln!(
|
||
"\n {art_name}: {} functions ({} prototypes, {} entity-IO + {} command + {} VScript bindings, \
|
||
{} descriptions), {} unresolved, schema {}",
|
||
rosetta.functions.len(),
|
||
j.prototypes,
|
||
j.bindings.entity_input,
|
||
j.bindings.command,
|
||
j.bindings.vscript,
|
||
j.descriptions,
|
||
rosetta.unresolved.len(),
|
||
match &rosetta.schema {
|
||
Some(s) => format!("{} classes", s.classes.len()),
|
||
None => "null (offline build)".to_string(),
|
||
}
|
||
);
|
||
let u = &rosetta.surfaces.unjoined;
|
||
eprintln!(
|
||
" surfaces: {} Pulse bindings, {} entity outputs, {} classnames, {} ConVars, \
|
||
{} declared rows with no function record of their own ({} entity-IO, {} commands, {} VScript)",
|
||
rosetta.surfaces.pulse.len(),
|
||
rosetta.surfaces.entity_outputs.len(),
|
||
rosetta.surfaces.entity_classes.len(),
|
||
rosetta.surfaces.convars.len(),
|
||
u.entity_inputs.len() + u.commands.len() + u.vscript.len(),
|
||
u.entity_inputs.len(),
|
||
u.commands.len(),
|
||
u.vscript.len()
|
||
);
|
||
let mut artifacts = vec![art_name];
|
||
|
||
// 7. 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.
|
||
if let Some(model) = cmodel {
|
||
eprintln!("\n===== fold model N -> N+1 (sidecar) =====");
|
||
let model_name = format!("model-{token}.json");
|
||
fold_model_cmd(prof, model, catalogue, target, class_scope, &p(&model_name))?;
|
||
artifacts.push(model_name);
|
||
}
|
||
|
||
// 8. the interop manifest.
|
||
let manifest = json!({ "version": version, "artifacts": artifacts });
|
||
std::fs::write(p("manifest.json"), serde_json::to_string_pretty(&manifest)?)?;
|
||
eprintln!(
|
||
"\nproduce {version} -> {} ({} artifacts)",
|
||
out_dir.display(),
|
||
artifacts.len()
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
/// Multiset of normalized function-body digests for one image: digest -> how many functions carry it.
|
||
/// Identical-code-folded clones (many tiny thunks share a body) collapse to one digest with a count >1,
|
||
/// which the min-count intersection in `classify_change_cmd` then handles exactly.
|
||
///
|
||
/// **Enumerated over the same union `xref::XrefIndex::build` uses, and it has to be.** CS2 strips
|
||
/// `.eh_frame` from the game code — `.eh_frame_hdr` describes 8,327 of libserver's ~70,000 functions, all
|
||
/// of them in the statically-linked runtime tail — so digesting the FDE list alone samples the ~12% of the
|
||
/// binary least likely to change and calls it the whole. A gameplay-only patch then shows zero changed
|
||
/// digests and `classify-change` answers `skip`: "no release needed", about a build whose gamedata moved.
|
||
/// Relocation code-pointers ∪ decoded call targets ∪ FDE starts covers the gameplay region too.
|
||
///
|
||
/// A `[start, next_start)` range can span a real function plus an unindexed neighbour, so a digest is not
|
||
/// a claim about one function. It does not need to be: what both callers compare is the MULTISET, and a
|
||
/// range that is stable across two builds carries the same digest in both whatever it contains.
|
||
fn function_digests(img: &CodeImage) -> HashMap<u64, u32> {
|
||
let entries = crate::locate::function_entries(img);
|
||
|
||
let mut m: HashMap<u64, u32> = HashMap::new();
|
||
for (i, &start) in entries.iter().enumerate() {
|
||
// The last entry runs to the end of its executable block, which `normalized_digest` clamps to.
|
||
let end = entries.get(i + 1).copied().unwrap_or(u64::MAX);
|
||
if let Some(d) = emit::normalized_digest(img, start, end) {
|
||
*m.entry(d).or_default() += 1;
|
||
}
|
||
}
|
||
m
|
||
}
|
||
|
||
/// Function-body change between two builds' digest multisets. `common` = body-identical functions
|
||
/// (the multiset intersection); everything else is derived. Shared by `classify-change` and
|
||
/// `filter-corpus` so their notion of "changed" is one definition.
|
||
struct DigestChange {
|
||
n_prev: u32,
|
||
n_new: u32,
|
||
common: u32,
|
||
}
|
||
|
||
/// The change class between two builds — a closed domain both CI commands decide identically from a
|
||
/// `DigestChange` (via [`DigestChange::classify`]) and serialize to its lowercase name, so `classify-change`
|
||
/// and `filter-corpus` can't drift on what "shift" means.
|
||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||
enum ChangeVerdict {
|
||
Skip,
|
||
Normal,
|
||
Shift,
|
||
}
|
||
|
||
impl ChangeVerdict {
|
||
fn as_str(self) -> &'static str {
|
||
match self {
|
||
ChangeVerdict::Skip => "skip",
|
||
ChangeVerdict::Normal => "normal",
|
||
ChangeVerdict::Shift => "shift",
|
||
}
|
||
}
|
||
}
|
||
|
||
impl DigestChange {
|
||
fn changed(&self) -> u32 {
|
||
self.n_new - self.common // new-side functions with no body-identical prior = added or edited
|
||
}
|
||
fn removed(&self) -> u32 {
|
||
self.n_prev - self.common // prev-side functions gone or edited
|
||
}
|
||
fn frac(&self) -> f64 {
|
||
if self.n_new == 0 {
|
||
0.0
|
||
} else {
|
||
self.changed() as f64 / self.n_new as f64
|
||
}
|
||
}
|
||
|
||
/// Classify this change: `Skip` if code-identical (0 changed) or below the skip tolerance, `Shift` if a
|
||
/// broad codegen change (at/above the shift threshold), else `Normal`. The one rule both CI commands share.
|
||
fn classify(&self, skip_below: f64, shift_above: f64) -> ChangeVerdict {
|
||
if self.changed() == 0 || self.frac() < skip_below {
|
||
ChangeVerdict::Skip
|
||
} else if self.frac() >= shift_above {
|
||
ChangeVerdict::Shift
|
||
} else {
|
||
ChangeVerdict::Normal
|
||
}
|
||
}
|
||
}
|
||
|
||
fn digest_change(prev: &HashMap<u64, u32>, new: &HashMap<u64, u32>) -> DigestChange {
|
||
let common = new
|
||
.iter()
|
||
.map(|(d, &nb)| nb.min(prev.get(d).copied().unwrap_or(0)))
|
||
.sum();
|
||
DigestChange {
|
||
n_prev: prev.values().sum(),
|
||
n_new: new.values().sum(),
|
||
common,
|
||
}
|
||
}
|
||
|
||
/// Classify the change between two builds of `lib` into `skip` / `normal` / `shift` (see the `Cmd`
|
||
/// doc). The signal is the fraction of the NEW build's functions whose masked body isn't byte-identical
|
||
/// to any function in the previous build — added-or-edited functions over the population we'd re-derive
|
||
/// against. A toolchain shift regenerates codegen everywhere, so that fraction spikes near 1; an ordinary
|
||
/// patch touches a few percent; an unchanged rebuild is ~0.
|
||
pub fn classify_change_cmd(
|
||
prev: &Path,
|
||
new: &Path,
|
||
lib: &str,
|
||
skip_below: f64,
|
||
shift_above: f64,
|
||
json: bool,
|
||
) -> Result<()> {
|
||
ensure!(
|
||
skip_below <= shift_above,
|
||
"--skip-below ({skip_below}) must be <= --shift-above ({shift_above})"
|
||
);
|
||
let da = function_digests(&load_lib(prev, lib)?);
|
||
let db = function_digests(&load_lib(new, lib)?);
|
||
ensure!(
|
||
!da.is_empty() && !db.is_empty(),
|
||
"no functions enumerated in one of the builds — refusing to classify rather than report `skip` \
|
||
from an empty sample"
|
||
);
|
||
let ch = digest_change(&da, &db);
|
||
let (n_prev, n_new, common, changed, removed, frac) = (
|
||
ch.n_prev,
|
||
ch.n_new,
|
||
ch.common,
|
||
ch.changed(),
|
||
ch.removed(),
|
||
ch.frac(),
|
||
);
|
||
|
||
// `skip` iff the library is code-identical (0 functions changed) — the common CI case of a buildid
|
||
// bump that didn't touch server code, where the gamedata provably can't have changed. `--skip-below`
|
||
// widens this to a small tolerance; by default only exact code-identity skips, so any real patch
|
||
// re-derives. `shift` = a broad codegen change (toolchain/build-env) — still just a re-derive, but one
|
||
// where most byte-sigs have drifted so recovery leans on string-anchors + vtable slots.
|
||
let verdict = ch.classify(skip_below, shift_above);
|
||
|
||
if json {
|
||
println!(
|
||
"{}",
|
||
json!({
|
||
"verdict": verdict.as_str(),
|
||
"changed_fraction": frac,
|
||
"changed": changed,
|
||
"common": common,
|
||
"removed": removed,
|
||
"functions_prev": n_prev,
|
||
"functions_new": n_new,
|
||
})
|
||
);
|
||
} else {
|
||
let hint = match verdict {
|
||
ChangeVerdict::Skip => "no meaningful code change — no release needed",
|
||
ChangeVerdict::Shift => "toolchain/compiler shift — re-derive (most byte-sigs drifted)",
|
||
ChangeVerdict::Normal => "ordinary patch — re-derive from the current model",
|
||
};
|
||
println!("verdict: {} ({hint})", verdict.as_str());
|
||
println!(
|
||
"changed: {:.2}% ({changed}/{n_new} new functions not body-identical to prev)",
|
||
frac * 100.0
|
||
);
|
||
println!(" body-identical (common): {common}");
|
||
println!(" prev-only (removed/edited): {removed}");
|
||
println!(" functions: prev {n_prev}, new {n_new}");
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// One build kept by the change-aware filter — a code-distinct representative of a run of otherwise
|
||
/// code-identical builds.
|
||
#[derive(serde::Serialize)]
|
||
struct KeptBuild {
|
||
label: String,
|
||
date: String,
|
||
/// Toolchain era (0-based); incremented at every `shift` transition.
|
||
era: u32,
|
||
/// Drift class from the PREVIOUS kept build: "normal" | "shift" | absent (the first kept build).
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
drift: Option<&'static str>,
|
||
/// Changed-fraction from the previous kept build (0 for the first).
|
||
fraction: f64,
|
||
/// How many later code-identical builds this representative absorbed.
|
||
collapsed: u32,
|
||
}
|
||
|
||
/// Stage-1 change-aware corpus filter. Walks a game's builds chronologically, collapses runs of
|
||
/// code-identical builds (bodies unchanged, only relocations moved — no gamedata delta) to ONE
|
||
/// representative, labels each surviving transition `normal`/`shift`, and segments the timeline into
|
||
/// toolchain ERAS (cut at every `shift`). Emits a selection manifest (the code-distinct kept builds,
|
||
/// each with its era + drift). Lossless for the per-game facts (a collapsed build's hops are identity)
|
||
/// and the distinct-build set `corpus-model` distills. Same masked-digest change
|
||
/// signal as `classify-change`, but digesting each build ONCE in parallel (not pairwise-redundant).
|
||
pub fn filter_corpus_cmd(
|
||
prof: &GameProfile,
|
||
corpus: &Path,
|
||
lib: &str,
|
||
skip_below: f64,
|
||
shift_above: f64,
|
||
out: Option<&Path>,
|
||
threads: Option<usize>,
|
||
) -> Result<()> {
|
||
ensure!(
|
||
skip_below <= shift_above,
|
||
"--skip-below ({skip_below}) must be <= --shift-above ({shift_above})"
|
||
);
|
||
let builds = find_builds(prof, corpus)?; // chronological: labels are date-prefixed, sorted
|
||
ensure!(
|
||
builds.len() >= 2,
|
||
"need >=2 builds under {}",
|
||
corpus.display()
|
||
);
|
||
let nthreads = default_threads(threads);
|
||
eprintln!(
|
||
"filter-corpus: digesting {} builds of {lib} ({nthreads} threads) ...",
|
||
builds.len()
|
||
);
|
||
|
||
// Digest each build ONCE, in chronological order (parallel_map preserves input order).
|
||
let digested: Vec<(String, HashMap<u64, u32>)> = parallel_map(&builds, nthreads, |dir| {
|
||
let dig = find_file(dir, lib, 8)
|
||
.and_then(|p| CodeImage::load(&p).ok())
|
||
.map(|img| function_digests(&img))
|
||
.unwrap_or_default();
|
||
(label_of(dir), dig)
|
||
});
|
||
|
||
let mut kept: Vec<KeptBuild> = Vec::new();
|
||
let mut dropped: BTreeMap<String, String> = BTreeMap::new();
|
||
let mut shifts: Vec<Value> = Vec::new();
|
||
let mut era = 0u32;
|
||
let mut unreadable = 0u32;
|
||
let mut last_kept: Option<usize> = None; // index into `digested` of the last kept build
|
||
|
||
for (i, (label, dig)) in digested.iter().enumerate() {
|
||
if dig.is_empty() {
|
||
unreadable += 1; // couldn't load/decode — neither kept nor dropped
|
||
continue;
|
||
}
|
||
let Some(lk) = last_kept else {
|
||
kept.push(KeptBuild {
|
||
label: label.clone(),
|
||
date: build_date(label),
|
||
era: 0,
|
||
drift: None,
|
||
fraction: 0.0,
|
||
collapsed: 0,
|
||
});
|
||
last_kept = Some(i);
|
||
continue;
|
||
};
|
||
let ch = digest_change(&digested[lk].1, dig);
|
||
match ch.classify(skip_below, shift_above) {
|
||
ChangeVerdict::Skip => {
|
||
// Code-identical to the last kept build — collapse it into that representative.
|
||
dropped.insert(label.clone(), digested[lk].0.clone());
|
||
if let Some(k) = kept.last_mut() {
|
||
k.collapsed += 1;
|
||
}
|
||
}
|
||
verdict => {
|
||
if verdict == ChangeVerdict::Shift {
|
||
era += 1;
|
||
shifts.push(
|
||
json!({ "label": label, "date": build_date(label), "fraction": ch.frac() }),
|
||
);
|
||
}
|
||
kept.push(KeptBuild {
|
||
label: label.clone(),
|
||
date: build_date(label),
|
||
era,
|
||
drift: Some(verdict.as_str()),
|
||
fraction: ch.frac(),
|
||
collapsed: 0,
|
||
});
|
||
last_kept = Some(i);
|
||
}
|
||
}
|
||
}
|
||
|
||
let (n_total, n_kept, n_dropped) = (builds.len(), kept.len(), dropped.len());
|
||
let n_eras = if n_kept == 0 { 0 } else { era + 1 };
|
||
eprintln!(
|
||
"filter-corpus: {n_total} builds -> {n_kept} code-distinct ({n_eras} eras, {} shifts), \
|
||
{n_dropped} code-identical dropped{}",
|
||
shifts.len(),
|
||
if unreadable > 0 {
|
||
format!(", {unreadable} unreadable")
|
||
} else {
|
||
String::new()
|
||
}
|
||
);
|
||
|
||
let manifest = json!({
|
||
"corpus": corpus.display().to_string(),
|
||
"lib": lib,
|
||
"skip_below": skip_below,
|
||
"shift_above": shift_above,
|
||
"builds_total": n_total,
|
||
"builds_kept": n_kept,
|
||
"builds_dropped": n_dropped,
|
||
"builds_unreadable": unreadable,
|
||
"eras": n_eras,
|
||
"shifts": shifts,
|
||
"kept": kept,
|
||
"dropped": dropped,
|
||
});
|
||
match out {
|
||
Some(p) => {
|
||
std::fs::write(p, serde_json::to_string_pretty(&manifest)?)?;
|
||
eprintln!(" wrote selection manifest -> {}", p.display());
|
||
}
|
||
None => println!("{}", serde_json::to_string_pretty(&manifest)?),
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════════════════════════
|
||
// §5 · LIVE ORACLE (semantic verification against a running server)
|
||
// ══════════════════════════════════════════════════════════════════════════════════════════════
|
||
|
||
/// The schema inheritance chain (class names, offset-0 bases) of `class` + its on-disk vtable slot
|
||
/// count — the set of classes whose methods a live instance of `class` actually carries, and how many
|
||
/// vtable slots it has.
|
||
fn pawn_chain(prof: &GameProfile, img: &CodeImage, class: &str) -> (HashSet<String>, usize) {
|
||
let classes = schema::enumerate_schema(img);
|
||
let by_name: HashMap<&str, &schema::SchemaClass> =
|
||
classes.iter().map(|c| (c.name.as_str(), c)).collect();
|
||
let mut chain = HashSet::new();
|
||
let mut cur = Some(class);
|
||
while let Some(name) = cur {
|
||
if !chain.insert(name.to_string()) {
|
||
break;
|
||
}
|
||
cur = by_name.get(name).and_then(|c| c.primary_base());
|
||
}
|
||
let n = rtti::find_vtable(img, class, prof.max_vtable_slots).map_or(0, |vt| vt.slots.len());
|
||
(chain, n)
|
||
}
|
||
|
||
/// The live-pawn context a semantic sweep reads and calls against: the process, its on-disk libserver
|
||
/// image + load slide, one live instance, and that instance's vtable slot count.
|
||
struct PawnProbe<'a> {
|
||
pid: u32,
|
||
live: &'a live::LiveProcess,
|
||
server_img: &'a CodeImage,
|
||
base: u64,
|
||
pawn: u64,
|
||
vt_slots: usize,
|
||
}
|
||
|
||
/// Semantic CALL sweep: the composition of the ABI shape (part a) with live calling (part b). For
|
||
/// every derived vtable-method offset the pawn actually carries whose ABI shape is `this`-only and
|
||
/// whose name reads as a pure query, CALL `vtable[offset](pawn)` via ptrace and confirm it returns
|
||
/// cleanly. This turns the single hand-picked IsPlayerPawn probe into a sweep over every safely-
|
||
/// callable derived method — the semantic gate for a wrong/absent offset. A faulting call is caught
|
||
/// and the process restored (call_remote suppresses the signal), so a bad offset degrades to a FLAG,
|
||
/// never a crash. It is a smoke test, not an identity proof: a clean return proves the slot is a
|
||
/// callable this-method, while the OFFLINE abi-diff proves the prototype itself didn't move.
|
||
fn callable_method_sweep(
|
||
prof: &GameProfile,
|
||
p: &PawnProbe,
|
||
gamedata: &GdMap,
|
||
chain: &HashSet<String>,
|
||
) -> Result<OracleCounts> {
|
||
// A transient read failure here is a property of the live process, not of the derived release. Aborting
|
||
// would propagate out of `run_live_oracle` and past `produce`'s fail-fast, throwing away a fully derived
|
||
// in-memory release over one unreadable address — so treat an unreadable pawn/slot the way every other
|
||
// per-slot failure in this loop is treated (and the way `fuzz_live_run` already does): record and move on.
|
||
let Ok(vtable_ptr) = p.live.read_u64(p.pawn) else {
|
||
eprintln!(
|
||
" sweep SKIPPED: pawn {:#x} vtable unreadable (object freed mid-sweep?)",
|
||
p.pawn
|
||
);
|
||
return Ok(OracleCounts::default());
|
||
};
|
||
let (mut attempted, mut clean, mut not_this_only, mut not_query) = (0u32, 0u32, 0u32, 0u32);
|
||
let mut faulted: Vec<String> = Vec::new();
|
||
// Deterministic order: gamedata is a key-sorted map, iterate it directly.
|
||
for (name, entry) in gamedata {
|
||
let Some(off) = render::entry_from_value(entry).offset else {
|
||
continue; // not a vtable-method offset (a signature or field)
|
||
};
|
||
if off < 0 || (off as usize) >= p.vt_slots || !chain.contains(class_of(name)) {
|
||
continue; // not a method this pawn carries at a valid slot
|
||
}
|
||
if !is_query_method(prof, name) {
|
||
not_query += 1;
|
||
continue;
|
||
}
|
||
let Ok(func) = p.live.read_u64(vtable_ptr + off as u64 * 8) else {
|
||
faulted.push(format!("{name} — slot {off} unreadable"));
|
||
continue;
|
||
};
|
||
if !p.live.is_exec(func) {
|
||
faulted.push(format!("{name} — slot {off} not executable"));
|
||
continue;
|
||
}
|
||
// Shape the target from the on-disk image (file vaddr = runtime - slide); only invoke a
|
||
// this-only method (int_args<=1, no float/stack args) — anything else needs arguments we
|
||
// don't have, so calling it would pass garbage.
|
||
let this_only = abi::abi_shape(p.server_img, func.wrapping_sub(p.base))
|
||
.is_some_and(|s| s.is_this_only());
|
||
if !this_only {
|
||
not_this_only += 1;
|
||
continue;
|
||
}
|
||
attempted += 1;
|
||
match live::call_remote(p.pid as i32, func, &[p.pawn]) {
|
||
Ok(r) if r.clean_return => clean += 1,
|
||
Ok(_) => faulted.push(format!("{name} — offset {off} faulted (wrong offset?)")),
|
||
Err(e) => faulted.push(format!("{name} — call errored: {e}")),
|
||
}
|
||
}
|
||
println!(
|
||
" called {clean}/{attempted} this-only query methods cleanly on the live pawn \
|
||
({not_this_only} need args, {not_query} not query-safe — skipped)"
|
||
);
|
||
for f in faulted.iter().take(40) {
|
||
println!(" FLAG {f}");
|
||
}
|
||
if faulted.len() > 40 {
|
||
println!(" … and {} more", faulted.len() - 40);
|
||
}
|
||
Ok(OracleCounts {
|
||
checked: attempted,
|
||
ok: clean,
|
||
faulted: faulted.len() as u32,
|
||
})
|
||
}
|
||
|
||
/// Deterministic 64-bit LCG (standard Numerical-Recipes constants) — reproducible randomized sequencing for the
|
||
/// live fuzzer, no `rand` dep. A found crash replays exactly given the same `--seed`.
|
||
struct Lcg(u64);
|
||
impl Lcg {
|
||
fn next(&mut self) -> u64 {
|
||
self.0 = self
|
||
.0
|
||
.wrapping_mul(6364136223846793005)
|
||
.wrapping_add(1442695040888963407);
|
||
// Return the HIGH bits. In a power-of-two-modulus LCG, bit k has period 2^(k+1), so the low bits
|
||
// are nearly constant: returning them would make `below(2)` — the call-vs-netvar coin — a period-4
|
||
// sequence, and an iteration over a class with both methods and fields draws exactly 4 times, which
|
||
// would lock the coin to one value across consecutive such iterations and starve one probe kind.
|
||
self.0 >> 33
|
||
}
|
||
fn below(&mut self, n: usize) -> usize {
|
||
if n == 0 {
|
||
0
|
||
} else {
|
||
(self.next() % n as u64) as usize
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A live class the fuzzer can probe: how to find its instances (on-disk vtable slot0 → runtime via
|
||
/// slide), its safely-callable this-only query methods (from the derived gamedata), and its schema
|
||
/// fields (for netvar reads).
|
||
struct LiveClass {
|
||
vtable_slot0: u64,
|
||
query_methods: Vec<(String, usize)>, // (gamedata name, vtable slot)
|
||
fields: Vec<(String, i32)>, // (netvar, offset)
|
||
}
|
||
|
||
/// What a live oracle stage actually concluded, as data rather than as stdout. The stages compute a
|
||
/// verdict and then print it; returning it lets `run_live_oracle` gate on the numbers and lets CI branch
|
||
/// on them, which is the whole point of running the checks. Absent this, the netvars half of a release
|
||
/// was governed by nothing but a human reading a log line.
|
||
#[derive(Default, Clone, Copy)]
|
||
pub(crate) struct OracleCounts {
|
||
pub(crate) checked: u32,
|
||
pub(crate) ok: u32,
|
||
pub(crate) faulted: u32,
|
||
}
|
||
|
||
impl OracleCounts {
|
||
/// Fraction of checks that passed, 1.0 when nothing was checked (a stage that had nothing to do is
|
||
/// not a failure — a stage that checked things and mostly failed is).
|
||
fn pass_rate(&self) -> f64 {
|
||
if self.checked == 0 {
|
||
1.0
|
||
} else {
|
||
self.ok as f64 / self.checked as f64
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Minimum share of live checks that must pass before a release is allowed through. Set below the
|
||
/// clean-run pass rate, not at it: the sweep legitimately faults on methods whose real prototype needs
|
||
/// arguments, and the schema walk races entities being freed. This is a floor that catches "the layout
|
||
/// moved and nearly everything now fails", not a target to tune. A clean run passes near 100% on both
|
||
/// the schema-layout and call-sweep stages, so 0.90 leaves real headroom.
|
||
const ORACLE_MIN_PASS: f64 = 0.90;
|
||
|
||
/// Below this many checks, a pass RATE is noise and the floor is not applied — the stage still reports.
|
||
/// The call sweep legitimately checks only a handful of methods (most vtable entries either need
|
||
/// arguments or aren't query-safe), where a single transient fault is a large fraction and would block
|
||
/// an otherwise clean release. Rate gating needs a denominator big enough to mean something; the wide
|
||
/// schema walk is the case this floor is really for.
|
||
const ORACLE_MIN_SAMPLE: u32 = 25;
|
||
|
||
/// Minimum fraction of schema fields that must resolve to a runtime TYPE before the typed netvars ship.
|
||
/// `live_schema` reads each field's type through a SECOND runtime struct (`CSchemaType`, via F_TYPE/TY_NAME/
|
||
/// TY_CATEGORY) that the schema-layout oracle never exercises, defaulting to empty on any failure. If a build
|
||
/// moves those type-record offsets, every field resolves 'untyped', the layout gate still passes, and a
|
||
/// netvars file full of empty types would ship at exit 0 — the one thing the live walk adds over the offline
|
||
/// reader, ungated. Normal runs resolve ~100% typed (0 untyped observed), so a 0.5 floor only ever trips on a
|
||
/// 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 `produce`'s live stage and the
|
||
/// `integration-test` harness. Both own the server already, so there is no separate launch and no fixed
|
||
/// wall-clock: it runs exactly `iterations` probes and stops.
|
||
///
|
||
/// `seed` is passed FIXED (`0x5137`) by both callers and no flag varies it, so every run replays the same
|
||
/// probe sequence. That is deliberate for a release gate — a failure is reproducible by re-running the
|
||
/// same command — and it is the reason this is a smoke test rather than a search.
|
||
fn fuzz_live_run(
|
||
prof: &GameProfile,
|
||
pid: u32,
|
||
img: &CodeImage,
|
||
live: &live::LiveProcess,
|
||
base: u64,
|
||
doc: &GdMap,
|
||
iterations: usize,
|
||
seed: u64,
|
||
) -> Result<()> {
|
||
// Group derived vtable-method offsets by class; keep the this-only query methods of each class that
|
||
// has an RTTI vtable, plus that class's schema fields.
|
||
let schema_by_name: HashMap<String, Vec<(String, i32)>> = schema::enumerate_schema(img)
|
||
.into_iter()
|
||
.map(|c| {
|
||
(
|
||
c.name,
|
||
c.fields.into_iter().map(|f| (f.name, f.offset)).collect(),
|
||
)
|
||
})
|
||
.collect();
|
||
let mut by_class: BTreeMap<&str, Vec<(String, usize)>> = BTreeMap::new();
|
||
for (name, entry) in doc {
|
||
if !is_query_method(prof, name) {
|
||
continue;
|
||
}
|
||
if let Some(off) = render::entry_from_value(entry).offset
|
||
&& off >= 0
|
||
{
|
||
by_class
|
||
.entry(class_of(name))
|
||
.or_default()
|
||
.push((name.clone(), off as usize));
|
||
}
|
||
}
|
||
let mut classes: Vec<LiveClass> = Vec::new();
|
||
for (cname, methods) in by_class {
|
||
let Some(vt) = rtti::find_vtable(img, cname, prof.max_vtable_slots) else {
|
||
continue;
|
||
};
|
||
// keep only this-only methods (int_args<=1, no float/stack) at valid slots
|
||
let query_methods: Vec<(String, usize)> = methods
|
||
.into_iter()
|
||
.filter(|(_, slot)| {
|
||
*slot < vt.slots.len()
|
||
&& vt
|
||
.slots
|
||
.get(*slot)
|
||
.is_some_and(|&a| abi::abi_shape(img, a).is_some_and(|s| s.is_this_only()))
|
||
})
|
||
.collect();
|
||
let fields = schema_by_name.get(cname).cloned().unwrap_or_default();
|
||
if query_methods.is_empty() && fields.is_empty() {
|
||
continue;
|
||
}
|
||
// No `vt_slots` here: `query_methods` is already filtered to `slot < vt.slots.len()` above, so
|
||
// carrying the bound only to re-test it downstream reads as a guard that can fire when it cannot.
|
||
classes.push(LiveClass {
|
||
vtable_slot0: vt.slot0,
|
||
query_methods,
|
||
fields,
|
||
});
|
||
}
|
||
// A SMOKE TEST with nothing to probe is a smoke test that found no problems. Aborting here would
|
||
// fail a release that is already fully derived and live-validated, on the grounds that an optional
|
||
// extra check had no sample — the opposite of "degrade or stop loudly": the loud part is right, the
|
||
// stopping is not. Zero probeable classes is a real thing to say, so say it and return.
|
||
if classes.is_empty() {
|
||
eprintln!(
|
||
" fuzz-live SKIPPED: no probeable classes (no gamedata offset resolves to a class with an \
|
||
RTTI vtable and a this-only query method). Nothing was fuzzed; nothing failed."
|
||
);
|
||
return Ok(());
|
||
}
|
||
let n_methods: usize = classes.iter().map(|c| c.query_methods.len()).sum();
|
||
eprintln!(
|
||
"fuzz-live: {} probeable classes, {n_methods} this-only query methods, {} iterations, seed {seed}",
|
||
classes.len(),
|
||
iterations
|
||
);
|
||
|
||
let mut rng = Lcg(seed);
|
||
let (mut calls, mut clean, mut reads, mut readable, mut no_inst) =
|
||
(0u64, 0u64, 0u64, 0u64, 0u64);
|
||
let mut faults: BTreeMap<String, u64> = BTreeMap::new(); // probe -> fault count
|
||
for it in 0..iterations {
|
||
let c = &classes[rng.below(classes.len())];
|
||
let instances = live.find_instances(c.vtable_slot0 + base, 16);
|
||
if instances.is_empty() {
|
||
no_inst += 1;
|
||
continue;
|
||
}
|
||
let inst = instances[rng.below(instances.len())];
|
||
// half the time call a method, half read a netvar (whichever is available)
|
||
let do_call = !c.query_methods.is_empty() && (c.fields.is_empty() || rng.next() & 1 == 0);
|
||
if do_call {
|
||
let (name, slot) = &c.query_methods[rng.below(c.query_methods.len())];
|
||
let Ok(vtp) = live.read_u64(inst) else {
|
||
continue;
|
||
};
|
||
let Ok(func) = live.read_u64(vtp + *slot as u64 * 8) else {
|
||
continue;
|
||
};
|
||
if !live.is_exec(func) {
|
||
*faults.entry(format!("{name} (slot not exec)")).or_default() += 1;
|
||
continue;
|
||
}
|
||
calls += 1;
|
||
match live::call_remote(pid as i32, func, &[inst]) {
|
||
Ok(r) if r.clean_return => clean += 1,
|
||
Ok(_) => *faults.entry(name.clone()).or_default() += 1,
|
||
Err(e) => {
|
||
// a vanished target may mean the server crashed — confirm by re-attaching.
|
||
if live::LiveProcess::attach(pid).is_err() {
|
||
eprintln!(
|
||
" !! server pid {pid} appears DEAD after calling {name} on {inst:#x} at iteration {it} — possible crash (seed {seed})"
|
||
);
|
||
anyhow::bail!("live server died during fuzzing (last probe: {name})");
|
||
}
|
||
*faults.entry(format!("{name} (call err: {e})")).or_default() += 1;
|
||
}
|
||
}
|
||
} else if !c.fields.is_empty() {
|
||
let (_fname, off) = &c.fields[rng.below(c.fields.len())];
|
||
reads += 1;
|
||
if *off >= 0 && live.read_i32(inst + *off as u64).is_ok() {
|
||
readable += 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
println!("fuzz-live done ({iterations} iterations):");
|
||
println!(" method calls : {clean}/{calls} returned cleanly");
|
||
println!(" netvar reads : {readable}/{reads} readable");
|
||
println!(" no-instance iterations (class had none live at the time): {no_inst}");
|
||
if faults.is_empty() {
|
||
println!(" no faults — every derived probe held across the sampled game states.");
|
||
} else {
|
||
println!(
|
||
" {} probe(s) FAULTED (offset may be wrong, or state-dependent):",
|
||
faults.len()
|
||
);
|
||
for (probe, n) in faults.iter().take(40) {
|
||
println!(" {n:>4}x {probe}");
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
pub fn integration_test_cmd(
|
||
prof: &GameProfile,
|
||
game: &Path,
|
||
build: Option<&Path>,
|
||
lib: &str,
|
||
wait: u64,
|
||
map: &str,
|
||
bots: u32,
|
||
gamedata: Option<&Path>,
|
||
out: Option<&Path>,
|
||
keep: bool,
|
||
fuzz_iterations: usize,
|
||
) -> Result<()> {
|
||
let build = build.unwrap_or(game);
|
||
// Read the gamedata to validate against into memory (run_live_oracle takes it as a string).
|
||
let gamedata = gamedata.map(std::fs::read_to_string).transpose()?;
|
||
// Launch the vanilla bots server (shared with produce) and run the identical live oracle on it.
|
||
let mut server = launch_bots_server(prof, game, build, lib, wait, map, bots)?;
|
||
let pid = server.pid;
|
||
let result = run_live_oracle(prof, pid, build, lib, gamedata.as_deref(), fuzz_iterations);
|
||
if keep {
|
||
eprintln!("\n(leaving server pid {pid} running as requested)");
|
||
} else {
|
||
let _ = server.child.kill();
|
||
let _ = server.child.wait();
|
||
eprintln!("\n(test server pid {pid} shut down)");
|
||
}
|
||
// Surface any oracle error only AFTER teardown; then, if requested, write the kept (live-valid) set.
|
||
let live_result = result?;
|
||
if let (Some(op), Some((kept, _))) = (out, &live_result) {
|
||
std::fs::write(
|
||
op,
|
||
serde_json::to_string_pretty(&Value::Object(kept.clone()))?,
|
||
)
|
||
.with_context(|| format!("write {}", op.display()))?;
|
||
eprintln!(" wrote {} kept entries -> {}", kept.len(), op.display());
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Resolve the live-oracle readiness anchor from the on-disk image: for a pawn game, the player-pawn
|
||
/// vtable + the health-netvar offset (poll for an ALIVE pawn); for a pawn-less game (Dota), the
|
||
/// gamerules-proxy vtable (a live instance = map loaded + libserver ready) and no health field.
|
||
fn resolve_ready_anchor(
|
||
prof: &GameProfile,
|
||
img: &CodeImage,
|
||
) -> Result<(rtti::VTable, Option<u64>)> {
|
||
match prof.pawn_anchor {
|
||
Some(pa) => {
|
||
let health = schema::enumerate_schema(img)
|
||
.iter()
|
||
.flat_map(|c| &c.fields)
|
||
.find(|f| f.name == pa.health_field)
|
||
.map(|f| f.offset as u64)
|
||
.context("health field offset")?;
|
||
let vt = rtti::find_vtable(img, pa.pawn_class, prof.max_vtable_slots)
|
||
.context("pawn vtable")?;
|
||
Ok((vt, Some(health)))
|
||
}
|
||
None => {
|
||
let cls = prof.ready_class;
|
||
let vt = rtti::find_vtable(img, cls, prof.max_vtable_slots)
|
||
.with_context(|| format!("{cls} vtable"))?;
|
||
Ok((vt, None))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The live-oracle pawn context: an alive player pawn plus the anchor and health-field offset needed to CALL
|
||
/// and sweep it. `Some` only when all three inputs are present (a pawn game with an alive pawn found); a
|
||
/// pawn-less game (Dota) or a not-yet-spawned pawn leaves it `None`, gating the pawn-only CALL test + sweep.
|
||
#[derive(Clone, Copy)]
|
||
struct PawnContext {
|
||
anchor: profile::PawnAnchor,
|
||
pawn: u64,
|
||
health: u64,
|
||
}
|
||
|
||
pub(crate) fn run_live_oracle(
|
||
prof: &GameProfile,
|
||
pid: u32,
|
||
build: &Path,
|
||
lib: &str,
|
||
gamedata: Option<&str>,
|
||
fuzz_iterations: usize,
|
||
) -> Result<Option<(GdMap, BTreeMap<String, Option<bool>>)>> {
|
||
let pawn_anchor = prof.pawn_anchor;
|
||
let img = load_lib(build, lib)?;
|
||
let (ready_vt, pawn_health) = resolve_ready_anchor(prof, &img)?;
|
||
|
||
let live = live::LiveProcess::attach(pid)?;
|
||
let base = live
|
||
.base(lib)
|
||
.with_context(|| format!("{lib} not mapped in pid {pid}"))?;
|
||
// The server is already ready (the caller launched + polled it); for a pawn game, grab the alive pawn.
|
||
let pawn = match pawn_health {
|
||
Some(health) => live
|
||
.find_instances(ready_vt.slot0 + base, 64)
|
||
.into_iter()
|
||
.find(|&o| live.read_i32(o + health).unwrap_or(0) > 0),
|
||
None => None,
|
||
};
|
||
// Group the three pawn Options into one context — present iff all three are (and `pawn` is only `Some`
|
||
// when `pawn_health` is, so the context is `Some` exactly when a pawn game has an alive pawn).
|
||
let pawn_ctx = match (pawn_anchor, pawn, pawn_health) {
|
||
(Some(anchor), Some(pawn), Some(health)) => Some(PawnContext {
|
||
anchor,
|
||
pawn,
|
||
health,
|
||
}),
|
||
_ => 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)?));
|
||
|
||
// The CALL test needs a live player pawn; a pawn-less game (Dota) runs only the schema oracle above
|
||
// and the sig validation below.
|
||
if let Some(PawnContext {
|
||
anchor: pa,
|
||
pawn,
|
||
health,
|
||
}) = pawn_ctx
|
||
{
|
||
// A closure so a "nothing to test" exit skips the CALL test ALONE — the Pulse shim and descriptor
|
||
// oracles below are independent of it and must still run.
|
||
(|| {
|
||
println!("\n=== CALL test (ptrace injection — the thing read-only can't do) ===");
|
||
// The slot THIS build derived, never the constant frozen in the profile. `IsPlayerPawn` is an
|
||
// offset-only name, so `derive_offsets` drops it into `unresolved` whenever no anchor chains or
|
||
// the recency-weighted vote falls under the bar — and it does that precisely in the builds where
|
||
// the slot MOVED. Falling back to the frozen index there would be the worst possible moment:
|
||
// the catalogue shows this slot taking six distinct values in ten months, and the fallback would
|
||
// ptrace-CALL whatever now occupies a stale index, on the same process this run goes on to read
|
||
// typed netvars from and fuzz 500 times, then print PASS about an offset the gamedata lacks.
|
||
// `pa.is_player_pawn_slot` is therefore a RECORDED REFERENCE VALUE, cross-checked against and
|
||
// never itself called.
|
||
let derived = 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());
|
||
// The two ways of having no slot are different facts and get different sentences: an
|
||
// `integration-test` run with no `--gamedata` is normal, while a `produce` run that derived
|
||
// nothing is a statement about this build. One WARNING covering both blamed the derivation for
|
||
// the perfectly ordinary case.
|
||
let Some(is_player_pawn) = derived else {
|
||
match doc {
|
||
None => println!(
|
||
" no gamedata document supplied, so there is no derived slot to test — skipping. \
|
||
(`integration-test` without --gamedata; `produce` always supplies one.)"
|
||
),
|
||
Some(_) => eprintln!(
|
||
" WARNING this build derived NO IsPlayerPawn slot — it is offset-only, so it lands \
|
||
in `unresolved` when no anchor chains or the vote is under the bar. SKIPPING the \
|
||
CALL test rather than injecting a call at the profile's recorded {}. The semantic \
|
||
sweep below still covers every offset this build DID derive.",
|
||
pa.is_player_pawn_slot
|
||
),
|
||
}
|
||
return;
|
||
};
|
||
if is_player_pawn != pa.is_player_pawn_slot {
|
||
eprintln!(
|
||
" NOTE derived IsPlayerPawn slot {is_player_pawn} differs from the profile's recorded \
|
||
{} — using the derived one; update GameProfile::is_player_pawn_slot",
|
||
pa.is_player_pawn_slot
|
||
);
|
||
}
|
||
// A pawn selected back at pawn-probe time can be freed during the wide schema walk that ran in
|
||
// between (a bots-deathmatch pawn dies and is unmapped). That is a property of the live process,
|
||
// not of the fully-derived in-memory release — so a read failure here must DEGRADE rather than
|
||
// `?`-propagate past produce's fail-fast and abort. Each bail names the reason it actually hit:
|
||
// one message said "pawn became unreadable" for every exit, including the one where the pawn read
|
||
// fine and the SLOT was wrong, which reframed a stale offset as a benign live-process race.
|
||
let outcome = (|| -> Result<(), String> {
|
||
let hp = live
|
||
.read_i32(pawn + health)
|
||
.map_err(|_| format!("pawn {pawn:#x} became unreadable (freed mid-oracle?)"))?;
|
||
println!("alive pawn {pawn:#014x}, live m_iHealth = {hp}");
|
||
let vtable_ptr = live
|
||
.read_u64(pawn)
|
||
.map_err(|_| format!("pawn {pawn:#x} has no readable vtable pointer"))?;
|
||
let func = live
|
||
.read_u64(vtable_ptr + is_player_pawn * 8)
|
||
.map_err(|_| {
|
||
format!("slot {is_player_pawn} is not readable in the live vtable")
|
||
})?;
|
||
// 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) {
|
||
return Err(format!(
|
||
"slot {is_player_pawn} does not point at live executable code — the derived offset \
|
||
is wrong or stale, which is a fact about the BUILD, not about this process"
|
||
));
|
||
}
|
||
println!(
|
||
"calling IsPlayerPawn (gamedata vtable offset {is_player_pawn}, fn {func:#x}) on the live pawn..."
|
||
);
|
||
let r = live::call_remote(pid as i32, func, &[pawn])
|
||
.map_err(|e| format!("the call itself could not be made: {e:#}"))?;
|
||
let ret = r.rax & 0xff;
|
||
println!(
|
||
" -> returned {ret} (expect 1=true), clean_return={}",
|
||
r.clean_return
|
||
);
|
||
if r.clean_return && ret == 1 {
|
||
println!(
|
||
" PASS: gamedata offset {is_player_pawn} semantically IS IsPlayerPawn — verified by CALLING it."
|
||
);
|
||
} else {
|
||
println!(
|
||
" (unexpected result — offset may be wrong, or the pawn wasn't a player pawn)"
|
||
);
|
||
}
|
||
Ok(())
|
||
})();
|
||
if let Err(why) = outcome {
|
||
eprintln!(" CALL test SKIPPED: {why}");
|
||
}
|
||
})();
|
||
}
|
||
|
||
// The Pulse shim contract, checked by calling. Independent of `--gamedata`: it verifies a claim
|
||
// the binding registry makes, not a gamedata locator, so it runs on every live oracle.
|
||
println!(
|
||
"\n=== Pulse invocation shims: calling every `args-only` binding with a sentinel handle ==="
|
||
);
|
||
// Read once, used by both verifiers below — see `PulseRead`.
|
||
let pulse = PulseRead::of(&img, 8);
|
||
let (shims_probed, shims_ok, shims_faulted, shim_notes) =
|
||
verify_pulse_shims(&live, pid, base, &img, &pulse);
|
||
println!(" {shims_ok} / {shims_probed} returned cleanly");
|
||
for n in shim_notes.iter().take(8) {
|
||
println!(" {n}");
|
||
}
|
||
// Probing NOTHING must not read as a pass. `OracleCounts::pass_rate` returns 1.0 for zero checks —
|
||
// correct in general, since a stage with nothing to do is not a failure — but here zero means the
|
||
// eligibility filter stopped matching, which is precisely the silent collapse the emitted
|
||
// `call.needs` field would then be making claims about. The profile floor guarantees the callable
|
||
// rows exist, so an empty probe set is a contradiction worth shouting about.
|
||
if shims_probed == 0 {
|
||
println!(
|
||
" WARNING: no shim was eligible to probe. The floor guarantees host-callable rows exist, so \
|
||
this means the probe's own filter no longer matches them — the emitted `call.needs` is \
|
||
UNVERIFIED for this build."
|
||
);
|
||
}
|
||
// A fault means the emitted argument contract is wrong, which is a claim the artifact should not be
|
||
// making. Anything else (a clean non-`-2` return) is a different status protocol, not a broken contract,
|
||
// so it counts as OK.
|
||
verdicts.push((
|
||
"pulse-shims",
|
||
OracleCounts {
|
||
checked: shims_probed as u32,
|
||
ok: shims_ok as u32,
|
||
// COUNTED where the fault happens, not reconstructed by grepping the code's own prose for
|
||
// the word it printed — an edit to that sentence used to silently change this number.
|
||
faulted: shims_faulted as u32,
|
||
},
|
||
));
|
||
|
||
// The reconstructed Pulse signatures, against the descriptors the server actually holds. Like the
|
||
// shim check this verifies a claim the binding registry makes rather than a gamedata locator, so it
|
||
// runs on every live oracle.
|
||
println!(
|
||
"\n=== Pulse descriptors: the reconstructed signature vs the one the live server holds ==="
|
||
);
|
||
let (desc_checked, desc_agree, desc_notes) =
|
||
verify_pulse_descriptors(&live, pid, base, &img, &pulse);
|
||
println!(" {desc_agree} / {desc_checked} agree");
|
||
for n in desc_notes.iter().take(8) {
|
||
println!(" {n}");
|
||
}
|
||
if desc_checked == 0 {
|
||
println!(
|
||
" WARNING: no descriptor region could be read. Either the accessors stopped populating on \
|
||
call or the region layout moved — either way the emitted `params` are UNVERIFIED for this \
|
||
build."
|
||
);
|
||
}
|
||
verdicts.push((
|
||
"pulse-descriptors",
|
||
OracleCounts {
|
||
checked: desc_checked as u32,
|
||
ok: desc_agree as u32,
|
||
faulted: (desc_checked - desc_agree) as u32,
|
||
},
|
||
));
|
||
|
||
let live_result = if gamedata.is_some() {
|
||
println!("\n=== validate-live: derived gamedata vs the running server ===");
|
||
// 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, val_counts) = validate_live_cmd(prof, pid, build, &doc)?;
|
||
verdicts.push(("validate-live", val_counts));
|
||
// The semantic sweep + live fuzz operate on a live pawn; pawn-less games stop at sig validation.
|
||
if let Some(PawnContext {
|
||
anchor: pa, pawn, ..
|
||
}) = pawn_ctx
|
||
{
|
||
let (chain, vt_slots) = pawn_chain(prof, &img, pa.pawn_class);
|
||
println!(
|
||
"\n=== semantic call sweep (every this-only query method the pawn carries) ==="
|
||
);
|
||
let probe = PawnProbe {
|
||
pid,
|
||
live: &live,
|
||
server_img: &img,
|
||
base,
|
||
pawn,
|
||
vt_slots,
|
||
};
|
||
verdicts.push((
|
||
"call-sweep",
|
||
callable_method_sweep(prof, &probe, &doc, &chain)?,
|
||
));
|
||
if fuzz_iterations > 0 {
|
||
println!(
|
||
"\n=== live fuzz: {fuzz_iterations} randomized probes across live game state ==="
|
||
);
|
||
fuzz_live_run(prof, pid, &img, &live, base, &doc, fuzz_iterations, 0x5137)?;
|
||
}
|
||
}
|
||
Some((kept, entry_verdicts))
|
||
} else {
|
||
None
|
||
};
|
||
|
||
// Gate on what the stages concluded: a stage that only printed its verdict would leave a run where the
|
||
// schema layout moved and nearly every class mismatched indistinguishable, to the caller and to CI,
|
||
// from a clean one.
|
||
for (stage, c) in &verdicts {
|
||
eprintln!(
|
||
" oracle {stage}: {}/{} ok, {} faulted{}",
|
||
c.ok,
|
||
c.checked,
|
||
c.faulted,
|
||
if c.checked < ORACLE_MIN_SAMPLE {
|
||
" (below gating sample — reported only)"
|
||
} else {
|
||
""
|
||
}
|
||
);
|
||
ensure!(
|
||
c.checked < ORACLE_MIN_SAMPLE || c.pass_rate() >= ORACLE_MIN_PASS,
|
||
"live oracle stage `{stage}` passed only {}/{} checks ({:.1}%, floor {:.0}%) — the runtime \
|
||
layout likely moved; refusing to publish a release derived against it",
|
||
c.ok,
|
||
c.checked,
|
||
c.pass_rate() * 100.0,
|
||
ORACLE_MIN_PASS * 100.0
|
||
);
|
||
}
|
||
Ok(live_result)
|
||
}
|
||
|
||
/// The sentinel entity handle the Pulse resolve preamble rejects before dereferencing anything.
|
||
const PULSE_INVALID_HANDLE: u32 = 0xffff_ffff;
|
||
|
||
/// `PVAL_EHANDLE`, from the shipped `PulseValueType_t`.
|
||
const PULSE_EHANDLE: i32 = 13;
|
||
|
||
/// The owner-pointer offset within a VScript record. The STRIDE is `vscript::STRIDE` — the offline
|
||
/// reader's own, rather than a second copy of the same number that would have to be kept in step.
|
||
const VS_OWNER: u64 = 0x30;
|
||
|
||
/// Attribute every VScript binding to the class that owns it, from the running server.
|
||
///
|
||
/// **This is the one VScript field that cannot be derived offline**, and it is worth being precise about
|
||
/// why. The record stores its owner at `+0x30`, but the class descriptor reaches the initialiser through
|
||
/// a register loaded from memory rather than a `lea`, so constant propagation recovers the pointer for
|
||
/// exactly zero of the 1,841 bindings. Nothing about the fold can fix that; the value is not in the
|
||
/// instruction stream.
|
||
///
|
||
/// A running server has it. Every record in one array points at the same descriptor, and the descriptor's
|
||
/// own `+0x00` is its class-name string — the same 16-byte name-pair shape the records use. Walking that
|
||
/// chain attributes 1,724 of 1,809 members to 66 classes on Dota.
|
||
///
|
||
/// The precedent for a live-only field is the typed schema itself: a schema field's TYPE is a null
|
||
/// placeholder on disk and is populated only at runtime, which is why an offline run ships no typed
|
||
/// netvars at all. `class` behaves the same way — present in a full build, absent from an offline one —
|
||
/// rather than inventing a new kind of gap.
|
||
///
|
||
/// It matters because the consumers group by class. Both `api.json` and the `.d.ts` the Dota ecosystem
|
||
/// publishes declare members under their owning interface, so a flat function list is not renderable into
|
||
/// either.
|
||
fn attribute_vscript_classes(
|
||
live: &live::LiveProcess,
|
||
base: u64,
|
||
img: &CodeImage,
|
||
bindings: &mut model::Bindings,
|
||
) -> (usize, usize) {
|
||
let ident = |s: &str| {
|
||
!s.is_empty()
|
||
&& s.len() <= 128
|
||
&& s.chars()
|
||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == ':')
|
||
&& s.chars()
|
||
.next()
|
||
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
|
||
};
|
||
let name_at = |addr: u64| -> Option<String> {
|
||
live.read_u64(addr)
|
||
.ok()
|
||
.filter(|&p| p != 0)
|
||
.and_then(|p| live.read_cstr(p).ok())
|
||
.filter(|s| ident(s))
|
||
};
|
||
|
||
let mut of_member: HashMap<String, String> = HashMap::new();
|
||
// Anchor on a name the fold already recovered, then walk that record's whole array. Most anchors land
|
||
// in an array a previous one covered, so the skip keeps this O(classes) rather than O(bindings).
|
||
for f in crate::vscript::vscript_functions(img) {
|
||
if of_member.contains_key(&f.name) {
|
||
continue;
|
||
}
|
||
let mut needle = f.name.clone().into_bytes();
|
||
needle.push(0);
|
||
let Some(&sv) = img.find_bytes(&needle).first() else {
|
||
continue;
|
||
};
|
||
for h in live.find_instances(base + sv, 32) {
|
||
let Ok(owner) = live.read_u64(h + VS_OWNER) else {
|
||
continue;
|
||
};
|
||
let Some(cname) = (owner != 0).then(|| name_at(owner)).flatten() else {
|
||
continue;
|
||
};
|
||
// Members of one class are contiguous, and a change of owner is the array boundary — the
|
||
// only honest stopping condition. Walking until a record merely fails to validate over-runs
|
||
// into whatever is adjacent and attributes unrelated structures to the class.
|
||
for dir in [-1i64, 0, 1] {
|
||
let mut k = 0i64;
|
||
while let Some(addr) = h.checked_add_signed(k.wrapping_mul(crate::vscript::STRIDE))
|
||
{
|
||
if live.read_u64(addr + VS_OWNER).ok() != Some(owner) {
|
||
break;
|
||
}
|
||
if let Some(m) = name_at(addr) {
|
||
of_member.entry(m).or_insert_with(|| cname.clone());
|
||
}
|
||
if dir == 0 || k.abs() > 4096 {
|
||
break;
|
||
}
|
||
k += dir;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Count the classes ACTUALLY ASSIGNED, not the ones the walk encountered. Those differ: a class reached
|
||
// through a late anchor whose members a previous anchor already claimed is encountered and assigned
|
||
// nothing (`of_member` is first-writer-wins), so the encountered count drifts run to run with which
|
||
// instances the memory search happened to find — 63 on one Dota run and 66 on the next, both attributing
|
||
// an identical 1,638 bindings. The assigned count is the stable one and is also the number a consumer
|
||
// cares about: `gen`'s `moddota` format groups by class, so it is exactly how many interfaces
|
||
// they will emit.
|
||
let mut set = 0usize;
|
||
let mut assigned: HashSet<&str> = HashSet::new();
|
||
for b in &mut bindings.vscript {
|
||
if let Some(c) = of_member.get(&b.name) {
|
||
b.class = Some(c.clone());
|
||
set += 1;
|
||
}
|
||
}
|
||
assigned.extend(bindings.vscript.iter().filter_map(|b| b.class.as_deref()));
|
||
bindings.meta.vscript_classed = set;
|
||
(set, assigned.len())
|
||
}
|
||
|
||
/// Offset of the name POINTER within a Pulse descriptor element.
|
||
///
|
||
/// MEASURED against a populated region rather than taken from the reconstruction: `+0x00` holds a hash
|
||
/// token of the name, the string pointer is at `+0x08`, and the type follows at `+0x10`. The offline
|
||
/// reader never needs this because it collects identifier stores anywhere in the region and assigns them
|
||
/// by stride; a live read does, and assuming `+0x00` yields a page of spurious disagreements.
|
||
///
|
||
/// The element STRIDE is deliberately not a constant here. `pulse::read_all` derives it per image by
|
||
/// consensus and the fold reports it, so that a build which resizes the record shows up as a new number
|
||
/// rather than as lost signatures — a constant would make the offline reader adapt while this oracle
|
||
/// silently read the wrong addresses, and report a clean verdict for doing so.
|
||
const PULSE_ELEM_NAME: u64 = 0x08;
|
||
|
||
/// One read of the Pulse registry, shared by both live verifiers.
|
||
///
|
||
/// They opened with the same three lines and each re-walked the whole image for a registry the other had
|
||
/// already built. Reading it once also means both see the SAME derived stride, which is the property that
|
||
/// matters: two verifiers disagreeing about the record layout would disagree about which release is good.
|
||
struct PulseRead {
|
||
regs: Vec<crate::valvetab::PulseBinding>,
|
||
sigs: Vec<Option<crate::pulse::PulseSignature>>,
|
||
/// Element spacing, derived per image by consensus. `0` = no record answered unambiguously, which
|
||
/// `pulse::params_at` already handles by dropping every multi-element list.
|
||
stride: u64,
|
||
}
|
||
|
||
impl PulseRead {
|
||
fn of(img: &CodeImage, threads: usize) -> Self {
|
||
let regs = crate::valvetab::pulse_bindings(img);
|
||
let pairs: Vec<(u64, u64)> = regs
|
||
.iter()
|
||
.map(|b| (b.descriptor, b.arg_descriptor))
|
||
.collect();
|
||
let (sigs, stride, _, _) = crate::pulse::read_all(img, &pairs, threads);
|
||
PulseRead { regs, sigs, stride }
|
||
}
|
||
}
|
||
|
||
/// Check the reconstructed Pulse signatures against the descriptors the RUNNING server actually holds.
|
||
///
|
||
/// The Pulse signatures in `surfaces.pulse` are a constant-propagated reconstruction of an initialiser,
|
||
/// not a read of data — on disk the descriptor elements are zeroes, because they are written at runtime.
|
||
/// Nothing offline can confirm that reconstruction, which is why the multi-library duplicate check has
|
||
/// been able to report that the registry disagrees with itself (CS2 331 of 419 repeat registrations,
|
||
/// Dota 271 of 359) with no way to say which account was right.
|
||
///
|
||
/// A live server can say. The regions are plain statics, so at `slide + base` the real elements are
|
||
/// there — **once something has called the accessor.** They are lazy-init singletons and a server that
|
||
/// never executes a Pulse graph leaves every one of them zeroed, which is the normal case: a standard
|
||
/// match executes none. So the oracle calls them, and that is the whole trick.
|
||
///
|
||
/// **Why calling them is safe.** These are the `+24`/`+32` accessors the fold deliberately REFUSES to
|
||
/// treat as locators — nullary, measured `int=0 float=0`, whose entire body builds a static once and
|
||
/// returns it. They take no arguments to get wrong and touch no game state. The same objects that are
|
||
/// worthless as locators are exactly what makes this check possible.
|
||
fn verify_pulse_descriptors(
|
||
live: &live::LiveProcess,
|
||
pid: u32,
|
||
base: u64,
|
||
img: &CodeImage,
|
||
pulse: &PulseRead,
|
||
) -> (usize, usize, Vec<String>) {
|
||
let (regs, sigs) = (&pulse.regs, &pulse.sigs);
|
||
|
||
let (mut checked, mut agree) = (0usize, 0usize);
|
||
let mut notes: Vec<String> = Vec::new();
|
||
for (b, sig) in regs.iter().zip(sigs.iter()) {
|
||
let Some(sig) = sig.as_ref() else {
|
||
continue;
|
||
};
|
||
let Some((region, count)) = crate::pulse::record_region(img, b.descriptor) else {
|
||
continue;
|
||
};
|
||
// Populate the singleton. A failure here is not a defect in the artifact — it means the call did
|
||
// not land — so it drops out of the sample rather than counting against agreement.
|
||
if live::call_remote(pid as i32, base + b.descriptor, &[]).is_err() {
|
||
continue;
|
||
}
|
||
let mut live_names: Vec<String> = Vec::new();
|
||
let mut readable = true;
|
||
for i in 0..count {
|
||
let e = base + region + i * pulse.stride;
|
||
match live
|
||
.read_u64(e + PULSE_ELEM_NAME)
|
||
.ok()
|
||
.filter(|&p| p != 0)
|
||
.and_then(|p| live.read_cstr(p).ok())
|
||
{
|
||
Some(n) => live_names.push(n),
|
||
None => {
|
||
readable = false;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if !readable {
|
||
continue;
|
||
}
|
||
let offline: Vec<String> = sig.args.iter().map(|p| p.name.clone()).collect();
|
||
checked += 1;
|
||
if offline == live_names {
|
||
agree += 1;
|
||
} else if notes.len() < 8 {
|
||
notes.push(format!(
|
||
"{}: derived {offline:?} but the live descriptor holds {live_names:?}",
|
||
b.name
|
||
));
|
||
}
|
||
}
|
||
(checked, agree, notes)
|
||
}
|
||
|
||
/// Verify the emitted Pulse invocation shims by CALLING them — with a handle the engine must reject.
|
||
///
|
||
/// The binding registry states that a shim whose `call.needs` is `args-only` can be invoked by a host.
|
||
/// That is a claim about behaviour, so it is checked against behaviour rather than left as a derivation:
|
||
/// each eligible binding is called with a sentinel handle, and its resolve must return `-2` without
|
||
/// dereferencing anything. Confirming the ARGUMENT CONTRACT (the array at `r8+8+8k`, nulls in the slots the
|
||
/// measurement says are unread) is the point; the sentinel is what makes it free of side effects.
|
||
///
|
||
/// **Why this is safe to run in CI.** Every slot but the argument array is null, so a shim that misuses one
|
||
/// dereferences null and FAULTS — and a fault is caught, the signal suppressed and the thread restored. The
|
||
/// dangerous case is a valid-but-wrong pointer, which corrupts silently (see [`crate::taxonomy`]); this
|
||
/// passes none. The argument array points into the call's own dead stack scratch.
|
||
///
|
||
/// Eligibility is narrow on purpose: a shim, `args-only`, no declared return (so the output sink is never
|
||
/// needed), and a leading `PVAL_EHANDLE` (so the sentinel is rejected). Anything else is not probed.
|
||
///
|
||
/// Derived from the image with the same readers the fold uses, rather than read back from
|
||
/// the binding registry: the oracle runs for `integration-test` too, which never builds one, and
|
||
/// threading it through both callers to re-parse hex strings would verify the same claim by a longer route.
|
||
fn verify_pulse_shims(
|
||
live: &live::LiveProcess,
|
||
pid: u32,
|
||
base: u64,
|
||
img: &CodeImage,
|
||
pulse: &PulseRead,
|
||
) -> (usize, usize, usize, Vec<String>) {
|
||
let (regs, sigs) = (&pulse.regs, &pulse.sigs);
|
||
|
||
let mut probed = 0usize;
|
||
let mut bailed = 0usize;
|
||
let mut faulted = 0usize;
|
||
let mut bad: Vec<String> = Vec::new();
|
||
for (b, sig) in regs.iter().zip(sigs.iter()) {
|
||
let (Some(sig), true) = (sig.as_ref(), b.shim != 0) else {
|
||
continue;
|
||
};
|
||
let name = &b.name;
|
||
let callable =
|
||
crate::pulse::shim_reads(img, b.shim).is_some_and(|r| r.needs() == "args-only");
|
||
if !callable
|
||
|| !sig.returns.is_empty()
|
||
|| sig.args.first().map(|p| p.ty) != Some(PULSE_EHANDLE)
|
||
|| sig.args.len() > 2
|
||
{
|
||
continue;
|
||
}
|
||
let at = base + b.shim;
|
||
if !live.is_exec(at) {
|
||
continue;
|
||
}
|
||
// [0x00] padding — the array is addressed from +8 and nothing reads +0
|
||
// [0x08] pointer to argument 0 -> relocated to 0x20
|
||
// [0x10] pointer to argument 1 -> relocated to 0x24
|
||
// [0x20] the sentinel handle, [0x24] a zero second argument
|
||
let mut blob = [0u8; 0x28];
|
||
blob[0x20..0x24].copy_from_slice(&PULSE_INVALID_HANDLE.to_le_bytes());
|
||
let relocs: &[(usize, i64)] = if sig.args.len() >= 2 {
|
||
&[(0x08, 0x20), (0x10, 0x24)]
|
||
} else {
|
||
&[(0x08, 0x20)]
|
||
};
|
||
let args = [
|
||
live::Arg::Val(0),
|
||
live::Arg::Val(0),
|
||
live::Arg::Val(0),
|
||
live::Arg::Val(0),
|
||
live::Arg::Scratch(0),
|
||
live::Arg::Val(0),
|
||
];
|
||
probed += 1;
|
||
match live::call_remote_ex(
|
||
pid as i32,
|
||
at,
|
||
&args,
|
||
&[],
|
||
Some(live::Scratch {
|
||
bytes: &blob,
|
||
relocs,
|
||
}),
|
||
) {
|
||
Ok(r) if r.clean_return && r.rax as i32 == -2 => bailed += 1,
|
||
Ok(r) if r.clean_return => {
|
||
// A clean return that is not the bail path still proves the contract; only note it.
|
||
bailed += 1;
|
||
if bad.len() < 8 {
|
||
bad.push(format!(
|
||
"{name} returned {} (not -2), cleanly",
|
||
r.rax as i32
|
||
));
|
||
}
|
||
}
|
||
// A fault is the contract being WRONG — the shim was entered and the call did not survive.
|
||
// "Could not be called" is a different thing (the injection itself did not happen) and is
|
||
// deliberately NOT counted as a fault: it says nothing about the argument contract.
|
||
Ok(_) => {
|
||
faulted += 1;
|
||
bad.push(format!("{name} FAULTED — the argument contract is wrong"));
|
||
}
|
||
Err(e) => bad.push(format!("{name} could not be called: {e}")),
|
||
}
|
||
}
|
||
(probed, bailed, faulted, bad)
|
||
}
|
||
|
||
/// A launched, ready dedicated server the caller owns (must kill).
|
||
pub(crate) struct OwnedServer {
|
||
pub(crate) child: std::process::Child,
|
||
pub(crate) pid: u32,
|
||
}
|
||
|
||
/// Launch this game's vanilla dedicated server per [`GameProfile::launch`] (no mod, no human), wait for the
|
||
/// profile's readiness anchor — an alive bot PAWN for a pawn game, a live `ready_class` instance otherwise —
|
||
/// then hand back the process. The single CI server-launch, shared by `produce` and `integration-test`
|
||
/// (both call this). On timeout the child is killed + an error returned; on success the caller owns the
|
||
/// child.
|
||
pub(crate) fn launch_bots_server(
|
||
prof: &GameProfile,
|
||
game: &Path,
|
||
build: &Path,
|
||
lib: &str,
|
||
wait: u64,
|
||
map: &str,
|
||
bots: u32,
|
||
) -> Result<OwnedServer> {
|
||
let bindir = game.join("bin/linuxsteamrt64");
|
||
ensure!(
|
||
bindir.join(prof.executable).exists(),
|
||
"{} server executable `{}` not found at {}",
|
||
prof.display_name,
|
||
prof.executable,
|
||
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 + 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));
|
||
let log = std::fs::File::create(&logpath)?;
|
||
let args = prof.launch.args(map, bots);
|
||
let mut child = std::process::Command::new(&exe)
|
||
.current_dir(&bindir)
|
||
.env("LD_LIBRARY_PATH", &bindir)
|
||
.env("GLIBC_TUNABLES", "glibc.rtld.execstack=2")
|
||
.args(&args)
|
||
// Hold stdin open (piped, never written): a dedicated Dota server quits on stdin EOF. Harmless for CS2.
|
||
.stdin(std::process::Stdio::piped())
|
||
.stdout(log.try_clone()?)
|
||
.stderr(log)
|
||
.spawn()
|
||
.with_context(|| format!("launch {}", exe.display()))?;
|
||
let pid = child.id();
|
||
let ready_desc = if pawn_health.is_some() {
|
||
"an alive bot pawn"
|
||
} else {
|
||
"the map to load"
|
||
};
|
||
eprintln!(
|
||
"launched vanilla {} server (pid {pid}, {bots} bots, log {}). Waiting up to {wait}s for {ready_desc}...",
|
||
prof.display_name,
|
||
logpath.display()
|
||
);
|
||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(wait);
|
||
loop {
|
||
std::thread::sleep(std::time::Duration::from_secs(3));
|
||
// A server that dies during startup is a documented failure class here (without
|
||
// `sv_hibernate_when_empty 0` an empty dedicated server exits immediately, status 0). Notice it
|
||
// now: otherwise the loop burns the whole timeout and then blames readiness, never mentioning
|
||
// that the process is gone.
|
||
if let Ok(Some(status)) = child.try_wait() {
|
||
anyhow::bail!(
|
||
"server pid {pid} exited with {status} before becoming ready — see {}",
|
||
logpath.display()
|
||
);
|
||
}
|
||
// Each failure mode records WHY. Reporting only "not ready" conflates three very different causes —
|
||
// memory unreadable, the library never mapped, and the instance simply not spawned yet — and leaves
|
||
// the operator guessing at which.
|
||
let why = match live::LiveProcess::attach(pid) {
|
||
Err(e) => format!("cannot read pid {pid}'s memory: {e:#}"),
|
||
Ok(live) => match live.base(lib) {
|
||
None => format!("{lib} is not mapped in pid {pid}"),
|
||
Some(base) => {
|
||
let insts = live.find_instances(ready_vt.slot0 + base, 64);
|
||
let ready = match pawn_health {
|
||
Some(health) => insts
|
||
.iter()
|
||
.any(|&o| live.read_i32(o + health).unwrap_or(0) > 0),
|
||
None => !insts.is_empty(),
|
||
};
|
||
if ready {
|
||
return Ok(OwnedServer { child, pid }); // ready — hand off the child
|
||
}
|
||
match pawn_health {
|
||
Some(_) => format!("{} instance(s) found, none alive", insts.len()),
|
||
None => "no instance found".to_string(),
|
||
}
|
||
}
|
||
},
|
||
};
|
||
if std::time::Instant::now() >= deadline {
|
||
let _ = child.kill();
|
||
let _ = child.wait();
|
||
anyhow::bail!(
|
||
"not ready within {wait}s ({why}) — check {} (server up? map/bots loaded?)",
|
||
logpath.display()
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Validate a generated gamedata document against a LIVE server and RETURN the kept entries plus each
|
||
/// entry's honest verdict and this stage's pass tally — the headless CI self-check. **Nothing is written
|
||
/// here**; the caller persists what it needs (`integration-test --out`, or `produce`'s own merge).
|
||
///
|
||
/// Confident-bad entries (a sig that no longer resolves or does not execute, an offset whose slot is not
|
||
/// code) are dropped; ambiguous ones (base/derived offset, unknown class) are kept but reported, so
|
||
/// nothing valid is silently lost. The returned [`OracleCounts`] puts this stage under the same
|
||
/// `ORACLE_MIN_PASS` gate as every other one — it judges the tool's primary product, and used to be the
|
||
/// only stage whose tally was printed and discarded.
|
||
fn validate_live_cmd(
|
||
prof: &GameProfile,
|
||
pid: u32,
|
||
dir: &Path,
|
||
doc: &GdMap,
|
||
) -> Result<(GdMap, BTreeMap<String, Option<bool>>, OracleCounts)> {
|
||
let live = live::LiveProcess::attach(pid)?;
|
||
|
||
// Load EVERY on-disk library that is also mapped in the live process, so a class/sig in any lib
|
||
// (tier0 interfaces, networksystem, …) is validatable and an "unknown class" flag means a real
|
||
// non-vtable special, not a lib we skipped.
|
||
let mut lib_paths = Vec::new();
|
||
find_all_libs(dir, 8, &mut lib_paths);
|
||
// Several on-disk files can share a basename (a game tree carries loader shims named exactly like the
|
||
// engine libs they proxy), so first-hit-wins would validate against whichever the filesystem happened to
|
||
// enumerate first. The live process is the authority on which file is actually loaded: sort its mapped
|
||
// path to the front per basename, and fall back to the shallowest path when it reports none.
|
||
lib_paths.sort_by_key(|p| {
|
||
let mapped = p
|
||
.file_name()
|
||
.and_then(|s| s.to_str())
|
||
.and_then(|f| live.mapped_path(f))
|
||
.is_some_and(|m| Path::new(m) == p);
|
||
(!mapped, p.components().count(), p.clone())
|
||
});
|
||
let mut images: HashMap<String, CodeImage> = HashMap::new();
|
||
let mut bases: HashMap<String, u64> = HashMap::new();
|
||
for p in lib_paths {
|
||
let Some(fname) = p.file_name().and_then(|s| s.to_str()).map(str::to_string) else {
|
||
continue;
|
||
};
|
||
if images.contains_key(&fname) {
|
||
continue;
|
||
}
|
||
if let (Some(b), Ok(img)) = (live.base(&fname), CodeImage::load(&p)) {
|
||
images.insert(fname.clone(), img);
|
||
bases.insert(fname, b);
|
||
}
|
||
}
|
||
ensure!(
|
||
images.contains_key(prof.server_lib),
|
||
"libserver.so not mapped in pid {pid}"
|
||
);
|
||
let idx = build_vt_index(prof, &images);
|
||
eprintln!(
|
||
"indexed {} classes across {} mapped libs",
|
||
idx.by_name.len(),
|
||
images.len()
|
||
);
|
||
|
||
let mut kept = GdMap::new();
|
||
// Per-entry validation verdict for annotate_validation: Some(true) = live-confirmed, None = kept but
|
||
// UNVERIFIABLE this run (so the artifact must not claim validation), absent = dropped (=> Some(false)).
|
||
let mut verdicts: BTreeMap<String, Option<bool>> = BTreeMap::new();
|
||
let mut t = ValTally::default();
|
||
for (name, entry) in doc {
|
||
// EVERY locator the entry carries is checked, not the first one found. An entry may hold a
|
||
// signature AND a vtable offset — `model::Entry` says so, and five CS2 `core` entries do — and an
|
||
// `else if` here validated only the signature while the artifact went on to stamp
|
||
// `validated: true`, asserting a live check of a slot nobody read. Those slots ship into
|
||
// ModSharp's `VFuncs` and Metamod's `Offsets`, where a wrong one crashes the consumer.
|
||
let e = render::entry_from_value(entry);
|
||
let mut outcomes: Vec<LocatorOutcome> = Vec::new();
|
||
if let Some(sig) = e.signature {
|
||
let fname = lib_filename(prof, &sig.library);
|
||
let v = validate_sig(&images, &bases, &live, &fname, Some(sig.linux.as_str()));
|
||
outcomes.push(apply_sig(&mut t, name, v));
|
||
}
|
||
if let Some(off) = e.offset {
|
||
let class = class_of(name);
|
||
let v = validate_offset(&idx, class, off, &bases, &live);
|
||
outcomes.push(apply_offset(&mut t, name, off, class, v));
|
||
}
|
||
// Combining is where the honesty lives. `Some(true)` requires EVERY locator to have been
|
||
// confirmed; one confidently-bad locator drops the entry whatever the other says; and anything
|
||
// the oracle could not check degrades the whole entry to `None` rather than letting a checked
|
||
// half vouch for an unchecked one.
|
||
let verdict = if outcomes.is_empty() {
|
||
Some(None) // no locator at all — nothing to check, and nothing claimed
|
||
} else if outcomes.iter().any(|o| matches!(o, LocatorOutcome::Drop)) {
|
||
None // dropped: `annotate_validation` reads an absent verdict as Some(false)
|
||
} else if outcomes
|
||
.iter()
|
||
.all(|o| matches!(o, LocatorOutcome::Kept(Some(true))))
|
||
{
|
||
Some(Some(true))
|
||
} else if outcomes
|
||
.iter()
|
||
.any(|o| matches!(o, LocatorOutcome::Kept(Some(false))))
|
||
{
|
||
Some(Some(false))
|
||
} else {
|
||
Some(None)
|
||
};
|
||
if let Some(v) = verdict {
|
||
kept.insert(name.clone(), entry.clone());
|
||
verdicts.insert(name.clone(), v);
|
||
}
|
||
}
|
||
|
||
println!("validate-live vs pid {pid} ({} entries):", doc.len());
|
||
println!(
|
||
" signatures : {} live-valid ({} hooked), {} unvalidated (lib not mapped), {} dropped",
|
||
t.sig_ok, t.sig_hooked, t.sig_unval, t.sig_fail
|
||
);
|
||
println!(
|
||
" offsets : {} live-valid ({} via subclass), {} dropped (not code), {} flagged (oob), {} flagged (not a vtable class)",
|
||
t.off_live, t.off_sub, t.off_bad, t.off_oob, t.off_unknown
|
||
);
|
||
for d in &t.dropped {
|
||
println!(" DROP {d}");
|
||
}
|
||
for f in t.flagged.iter().take(60) {
|
||
println!(" FLAG {f}");
|
||
}
|
||
if t.flagged.len() > 60 {
|
||
println!(" … and {} more flags", t.flagged.len() - 60);
|
||
}
|
||
// The stage's own pass rate, returned so the release gate covers it like every other oracle stage.
|
||
// This is the one stage that judges the tool's PRIMARY product — the derived signatures and vtable
|
||
// offsets — and it was the one stage whose tally was printed and discarded. A derive that resolved
|
||
// onto the wrong functions (stale model, mismatched --target, a changed lib set) has every entry
|
||
// dropped, `annotate_validation` stamps `Some(false)` across core and high_confidence, and the release
|
||
// still wrote at exit 0. `Unvalidatable`/`Unknown` are excluded rather than counted as failures: the
|
||
// oracle could not judge them at all, which is what the three-valued `validated` already says.
|
||
let counts = OracleCounts {
|
||
checked: t.sig_ok + t.sig_fail + t.off_live + t.off_bad + t.off_oob,
|
||
ok: t.sig_ok + t.off_live,
|
||
faulted: t.sig_fail + t.off_bad + t.off_oob,
|
||
};
|
||
Ok((kept, verdicts, counts))
|
||
}
|
||
|
||
fn verify_live_cmd(prof: &GameProfile, pid: u32, dir: &Path, lib: &str) -> Result<OracleCounts> {
|
||
let img = load_lib(dir, lib)?;
|
||
let classes = schema::enumerate_schema(&img);
|
||
// A floor, not merely non-empty: this half compares an offline read against a live one through the same
|
||
// `CI_*` constants on the same bytes, so a reshape's survivors agree with themselves at ~1.0 and a
|
||
// handful of classes looks like a clean run.
|
||
//
|
||
// It is `min_schema_classes_LIB`, because this enumerates ONE library while `produce`'s floor counts the
|
||
// union across all of them — see `GameProfile::min_schema_classes_lib`. Only `server_lib` has a
|
||
// calibrated count, so any other library is enumerated and reported rather than judged against a number
|
||
// that does not describe it.
|
||
if lib == prof.server_lib {
|
||
ensure!(
|
||
classes.len() >= prof.min_schema_classes_lib,
|
||
"offline schema derivation found {} classes in {lib} (floor {}) — refusing to verify a schema \
|
||
whose class table collapsed",
|
||
classes.len(),
|
||
prof.min_schema_classes_lib
|
||
);
|
||
} else {
|
||
eprintln!(
|
||
"NOTE: {lib} is not {}, which is the only library with a calibrated class floor — \
|
||
enumerated {} classes, collapse check SKIPPED",
|
||
prof.server_lib,
|
||
classes.len()
|
||
);
|
||
}
|
||
|
||
let live = live::LiveProcess::attach(pid)?;
|
||
let base = live
|
||
.base(lib)
|
||
.with_context(|| format!("{lib} is not mapped in process {pid}"))?;
|
||
eprintln!(
|
||
"{lib} loaded at {base:#x} in pid {pid}; verifying {} schema classes...",
|
||
classes.len()
|
||
);
|
||
|
||
// Class-level checks across every derived class: (1) the relocated name pointer in live memory
|
||
// equals our offline `.rela.dyn`-resolved pointer + slide (our reloc logic == the loader);
|
||
// (2) name/size/field-count match; (3) m_pSchemaBinding is runtime-populated (we read live state).
|
||
let (mut reloc_ok, mut layout_ok, mut binding_live, mut checked, mut read_err) =
|
||
(0, 0, 0, 0, 0);
|
||
let mut mism: Vec<String> = Vec::new();
|
||
for c in &classes {
|
||
let rci = c.class_info + base; // runtime address of the SchemaClassInfoData_t
|
||
let res = (|| -> Result<()> {
|
||
checked += 1;
|
||
let live_name_ptr = live.read_u64(rci + schema::CI_NAME)?;
|
||
if live_name_ptr == c.name_ptr + base {
|
||
reloc_ok += 1;
|
||
}
|
||
let live_name = live.read_cstr(live_name_ptr)?;
|
||
let live_size = live.read_i32(rci + schema::CI_SIZE)?;
|
||
let live_fcount = live.read_u16(rci + schema::CI_FIELD_COUNT)? as usize;
|
||
if live_name == c.name && live_size == c.size && live_fcount == c.fields.len() {
|
||
layout_ok += 1;
|
||
} else if mism.len() < 5 {
|
||
mism.push(format!(
|
||
"{}: live(name={live_name:?} size={live_size} fc={live_fcount}) vs offline(size={} fc={})",
|
||
c.name, c.size, c.fields.len()
|
||
));
|
||
}
|
||
if live.read_u64(rci + schema::CI_BINDING)? != 0 {
|
||
binding_live += 1;
|
||
}
|
||
Ok(())
|
||
})();
|
||
if res.is_err() {
|
||
read_err += 1;
|
||
}
|
||
}
|
||
|
||
println!("Runtime oracle — offline schema vs live process {pid}:");
|
||
println!(" classes checked: {checked} ({read_err} unreadable)");
|
||
println!(
|
||
" relocation logic == loader: {reloc_ok}/{checked} name pointers match (offline .rela.dyn + slide == live)"
|
||
);
|
||
println!(" layout (name/size/#fields): {layout_ok}/{checked} match");
|
||
println!(
|
||
" m_pSchemaBinding populated: {binding_live}/{checked} (0 on disk -> proves we read LIVE runtime state)"
|
||
);
|
||
if !mism.is_empty() {
|
||
println!(" mismatches (sample): {mism:?}");
|
||
}
|
||
// The layout agreement IS the netvars verdict: `live_schema` takes field offsets from the offline
|
||
// image, and `model::Schema` has no per-field validated flag, so this is the only thing standing
|
||
// between a reshaped runtime struct and a shipped netvars file.
|
||
let counts = OracleCounts {
|
||
checked,
|
||
ok: layout_ok,
|
||
faulted: read_err,
|
||
};
|
||
|
||
// Spotlight: verify famous field offsets live, field-by-field (the netvars mods actually read).
|
||
for &(cls, want) in prof.spotlight_fields {
|
||
if let Some(c) = classes.iter().find(|c| c.name == cls) {
|
||
let fields_ptr = img.read_ptr(c.class_info + schema::CI_FIELDS).unwrap_or(0) + base;
|
||
println!(" {cls} fields read live from the running server:");
|
||
for &name in want {
|
||
if let Some((i, f)) = c.fields.iter().enumerate().find(|(_, f)| f.name == name) {
|
||
let live_off = live
|
||
.read_i32(
|
||
fields_ptr
|
||
.wrapping_add((i as u64).wrapping_mul(schema::F_STRIDE))
|
||
.wrapping_add(schema::F_OFFSET),
|
||
)
|
||
.unwrap_or(-1);
|
||
let tag = if live_off == f.offset {
|
||
"OK"
|
||
} else {
|
||
"MISMATCH"
|
||
};
|
||
println!(
|
||
" {name:<16} offline +{:<5} live +{live_off:<5} [{tag}]",
|
||
f.offset
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Ok(counts)
|
||
}
|
||
|
||
/// Verdict for one vtable-offset entry validated against the live process.
|
||
enum OffVerdict {
|
||
Live, // in-bounds; the live runtime vtable slot points at executable code
|
||
LiveViaSub(String), // valid at that slot on a subclass's vtable (the entry named a base class)
|
||
NotCode, // slot target isn't executable — a wrong offset
|
||
Oob, // slot exceeds the class's and its subclasses' vtables — review
|
||
Unknown, // not a vtable class in any mapped lib (an engine special / member offset)
|
||
}
|
||
|
||
/// Verdict for one signature entry validated against the live process.
|
||
enum SigVerdict {
|
||
Ok, // resolves uniquely to executable live code, bytes match disk
|
||
Hooked, // resolves fine but live bytes differ — a mod detoured it (sig is correct)
|
||
Unvalidatable(String), // its library isn't mapped in this process — keep + flag, can't check
|
||
Fail(String), // no match / ambiguous / non-executable — drop
|
||
}
|
||
|
||
/// Whole-binary vtable index over every mapped library: class name -> (lib, vtable vaddr, slot count)
|
||
/// for the primary vtable, plus base -> direct-subclass edges (to resolve base/derived offset naming).
|
||
struct VtIndex {
|
||
by_name: HashMap<String, (String, u64, usize)>,
|
||
subs: HashMap<String, Vec<String>>,
|
||
}
|
||
|
||
fn build_vt_index(prof: &GameProfile, images: &HashMap<String, CodeImage>) -> VtIndex {
|
||
// Enumerate every library's primary vtables in parallel (each is a full reloc-driven RTTI sweep),
|
||
// then merge in SORTED-filename order. Sorted (not HashMap-iteration) order makes the result
|
||
// deterministic: for a class exported by two libs the larger vtable wins, ties break by the
|
||
// earlier filename — previously the tie winner and the subclass-list order depended on hash order.
|
||
let mut names: Vec<&String> = images.keys().collect();
|
||
names.sort();
|
||
let per_lib: Vec<(&String, Vec<rtti::ClassVtable>)> =
|
||
parallel_map(&names, default_threads(None), |&fname| {
|
||
let cvs = rtti::enumerate_vtables(&images[fname], prof.max_vtable_slots)
|
||
.into_iter()
|
||
.filter(|cv| cv.offset_to_top == 0) // primary (complete-object) vtables only
|
||
.collect();
|
||
(fname, cvs)
|
||
});
|
||
|
||
let mut by_name: HashMap<String, (String, u64, usize)> = HashMap::new();
|
||
let mut subs: HashMap<String, Vec<String>> = HashMap::new();
|
||
for (fname, cvs) in &per_lib {
|
||
for cv in cvs {
|
||
let n = cv.slots.len();
|
||
by_name
|
||
.entry(cv.name.clone())
|
||
.and_modify(|e| {
|
||
if n > e.2 {
|
||
*e = ((*fname).clone(), cv.vtable_va, n);
|
||
}
|
||
})
|
||
.or_insert(((*fname).clone(), cv.vtable_va, n));
|
||
for b in &cv.bases {
|
||
subs.entry(b.name.clone())
|
||
.or_default()
|
||
.push(cv.name.clone());
|
||
}
|
||
}
|
||
}
|
||
VtIndex { by_name, subs }
|
||
}
|
||
|
||
/// Every `.so` under `dir` (bounded recursion) — the full library set to index.
|
||
fn find_all_libs(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {
|
||
if depth == 0 {
|
||
return;
|
||
}
|
||
let Ok(rd) = std::fs::read_dir(dir) else {
|
||
return;
|
||
};
|
||
for e in rd.flatten() {
|
||
let p = e.path();
|
||
if p.is_dir() {
|
||
find_all_libs(&p, depth - 1, out);
|
||
} else if p.extension().and_then(|s| s.to_str()) == Some("so") {
|
||
out.push(p);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Is the live runtime vtable slot `off` (of the vtable at file vaddr `va` in `fname`) executable?
|
||
fn live_slot_is_code(
|
||
va: u64,
|
||
fname: &str,
|
||
off: usize,
|
||
bases: &HashMap<String, u64>,
|
||
live: &live::LiveProcess,
|
||
) -> bool {
|
||
let Some(&base) = bases.get(fname) else {
|
||
return false;
|
||
};
|
||
let ptr = live.read_u64(va + base + off as u64 * 8).unwrap_or(0);
|
||
ptr != 0 && live.is_exec(ptr)
|
||
}
|
||
|
||
/// A method named on a base class may sit at slot `off` on a subclass's (larger) vtable. BFS the
|
||
/// subclass graph for one whose vtable holds an executable slot there; return that subclass's name.
|
||
fn resolve_via_subclass(
|
||
idx: &VtIndex,
|
||
class: &str,
|
||
off: usize,
|
||
bases: &HashMap<String, u64>,
|
||
live: &live::LiveProcess,
|
||
) -> Option<String> {
|
||
let mut seen: HashSet<String> = HashSet::new();
|
||
let mut frontier: Vec<String> = idx.subs.get(class).cloned().unwrap_or_default();
|
||
for _ in 0..6 {
|
||
let mut next = Vec::new();
|
||
for d in std::mem::take(&mut frontier) {
|
||
if !seen.insert(d.clone()) {
|
||
continue;
|
||
}
|
||
if let Some((fname, va, n)) = idx.by_name.get(&d)
|
||
&& off < *n
|
||
&& live_slot_is_code(*va, fname, off, bases, live)
|
||
{
|
||
return Some(d);
|
||
}
|
||
if let Some(s) = idx.subs.get(&d) {
|
||
next.extend(s.iter().cloned());
|
||
}
|
||
}
|
||
if next.is_empty() {
|
||
break;
|
||
}
|
||
frontier = next;
|
||
}
|
||
None
|
||
}
|
||
|
||
fn validate_offset(
|
||
idx: &VtIndex,
|
||
class: &str,
|
||
off: i64,
|
||
bases: &HashMap<String, u64>,
|
||
live: &live::LiveProcess,
|
||
) -> OffVerdict {
|
||
if off < 0 {
|
||
return OffVerdict::NotCode;
|
||
}
|
||
let off = off as usize;
|
||
match idx.by_name.get(class) {
|
||
Some((fname, va, n)) if off < *n => {
|
||
if live_slot_is_code(*va, fname, off, bases, live) {
|
||
OffVerdict::Live
|
||
} else {
|
||
OffVerdict::NotCode
|
||
}
|
||
}
|
||
Some(_) => match resolve_via_subclass(idx, class, off, bases, live) {
|
||
Some(d) => OffVerdict::LiveViaSub(d),
|
||
None => OffVerdict::Oob,
|
||
},
|
||
None => OffVerdict::Unknown,
|
||
}
|
||
}
|
||
|
||
/// A byte diff at a uniquely-resolved prologue means a mod detoured the function (evidence the sig is
|
||
/// correct) → `Hooked`, kept + flagged. Only a miss / ambiguous / non-executable target is a hard fail.
|
||
fn validate_sig(
|
||
images: &HashMap<String, CodeImage>,
|
||
bases: &HashMap<String, u64>,
|
||
live: &live::LiveProcess,
|
||
fname: &str,
|
||
linux: Option<&str>,
|
||
) -> SigVerdict {
|
||
let (Some(img), Some(&base)) = (images.get(fname), bases.get(fname)) else {
|
||
return SigVerdict::Unvalidatable(format!("lib {fname} not mapped in process"));
|
||
};
|
||
let Some(pat) = linux.and_then(|l| Pattern::parse(l).ok()) else {
|
||
return SigVerdict::Fail("absent/unparseable pattern".into());
|
||
};
|
||
let addr = match img.find(&pat).as_slice() {
|
||
[a] => *a,
|
||
[] => return SigVerdict::Fail("no live match".into()),
|
||
hits => return SigVerdict::Fail(format!("{} matches (ambiguous)", hits.len())),
|
||
};
|
||
let rt = addr + base;
|
||
if !live.is_exec(rt) {
|
||
return SigVerdict::Fail("resolved address not executable".into());
|
||
}
|
||
if let Some(disk) = img.code_at(addr) {
|
||
let n = disk.len().min(32);
|
||
let got = live.read_bytes(rt, n);
|
||
if got.len() == n && got != disk[..n] {
|
||
return SigVerdict::Hooked;
|
||
}
|
||
}
|
||
SigVerdict::Ok
|
||
}
|
||
|
||
/// Running tally for a validate-live pass (keeps the entry loop flat / low-complexity).
|
||
#[derive(Default)]
|
||
struct ValTally {
|
||
sig_ok: u32,
|
||
sig_hooked: u32,
|
||
sig_unval: u32,
|
||
sig_fail: u32,
|
||
off_live: u32,
|
||
off_sub: u32,
|
||
off_bad: u32,
|
||
off_oob: u32,
|
||
off_unknown: u32,
|
||
dropped: Vec<String>,
|
||
flagged: Vec<String>,
|
||
}
|
||
|
||
/// What the oracle concluded about ONE locator of an entry.
|
||
///
|
||
/// An entry may carry a signature AND a vtable offset — `model::Entry`'s own doc says so — and each is a
|
||
/// separate claim about the running server. Judging them separately, then combining, is what stops the
|
||
/// artifact stamping `validated: true` on an entry whose offset was never read.
|
||
enum LocatorOutcome {
|
||
/// Confidently bad: this locator does not describe the running server.
|
||
Drop,
|
||
/// Kept, with the honest three-valued verdict — `None` means the oracle could not check it.
|
||
Kept(Option<bool>),
|
||
}
|
||
|
||
fn apply_sig(t: &mut ValTally, name: &str, v: SigVerdict) -> LocatorOutcome {
|
||
match v {
|
||
SigVerdict::Ok => {
|
||
t.sig_ok += 1;
|
||
LocatorOutcome::Kept(Some(true))
|
||
}
|
||
SigVerdict::Hooked => {
|
||
t.sig_ok += 1;
|
||
t.sig_hooked += 1;
|
||
t.flagged.push(format!(
|
||
"{name} — sig valid; function is HOOKED live (a mod detoured it)"
|
||
));
|
||
LocatorOutcome::Kept(Some(true)) // the sig IS valid; a hook is a runtime overlay, not a mismatch
|
||
}
|
||
SigVerdict::Unvalidatable(why) => {
|
||
t.sig_unval += 1;
|
||
t.flagged
|
||
.push(format!("{name} — sig kept, unvalidated: {why}"));
|
||
LocatorOutcome::Kept(None) // lib not mapped this run -> never claim Some(true)
|
||
}
|
||
SigVerdict::Fail(why) => {
|
||
t.sig_fail += 1;
|
||
t.dropped.push(format!("{name} — sig: {why}"));
|
||
LocatorOutcome::Drop
|
||
}
|
||
}
|
||
}
|
||
|
||
fn apply_offset(
|
||
t: &mut ValTally,
|
||
name: &str,
|
||
off: i64,
|
||
class: &str,
|
||
v: OffVerdict,
|
||
) -> LocatorOutcome {
|
||
match v {
|
||
OffVerdict::Live => {
|
||
t.off_live += 1;
|
||
LocatorOutcome::Kept(Some(true))
|
||
}
|
||
OffVerdict::LiveViaSub(d) => {
|
||
t.off_live += 1;
|
||
t.off_sub += 1;
|
||
t.flagged.push(format!(
|
||
"{name} — offset {off} valid on subclass {d} (entry names a base class)"
|
||
));
|
||
LocatorOutcome::Kept(Some(true)) // the slot IS executable on a subclass vtable — validated
|
||
}
|
||
OffVerdict::NotCode => {
|
||
t.off_bad += 1;
|
||
t.dropped
|
||
.push(format!("{name} — offset {off}: slot not executable"));
|
||
LocatorOutcome::Drop
|
||
}
|
||
OffVerdict::Oob => {
|
||
t.off_oob += 1;
|
||
t.flagged.push(format!(
|
||
"{name} — offset {off} exceeds {class} and its subclasses' vtables"
|
||
));
|
||
LocatorOutcome::Kept(Some(false)) // exceeds the vtable = likely WRONG (render drops it)
|
||
}
|
||
OffVerdict::Unknown => {
|
||
t.off_unknown += 1;
|
||
t.flagged.push(format!(
|
||
"{name} — {class} is not a vtable class (engine special / member offset)"
|
||
));
|
||
LocatorOutcome::Kept(None) // not a vtable class -> the oracle cannot check it
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
// `DigestChange::classify` IS the CI branch decision: `skip` publishes only a manifest, `shift` marks a
|
||
// toolchain jump. The thresholds are user-tunable; the calibration behind the defaults is recorded ONCE,
|
||
// in `classify-change --shift-above`'s help. The cases below pin its extremes so a threshold edit has
|
||
// to face them — deliberately not restated here, since two copies of a measurement is how the last one
|
||
// came to be quoted long after it was superseded.
|
||
fn ch(n_new: u32, common: u32) -> DigestChange {
|
||
DigestChange {
|
||
n_prev: n_new,
|
||
n_new,
|
||
common,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn classify_defaults_straddle_the_measured_gap() {
|
||
// The extremes measured over 344 CS2 builds at ~70,300 functions each. The two INNER values are
|
||
// the ones that matter: the largest ordinary patch and the smallest toolchain jump sit 4.6
|
||
// points apart, and the default has to land between them.
|
||
let (skip_below, shift_above) = (0.0, 0.20);
|
||
// code-identical -> skip (82 of 344 builds)
|
||
assert!(matches!(
|
||
ch(10_000, 10_000).classify(skip_below, shift_above),
|
||
ChangeVerdict::Skip
|
||
));
|
||
// the smallest real patch, 0.001% -> normal, NOT skip, at the default 0 tolerance
|
||
assert!(matches!(
|
||
ch(100_000, 99_999).classify(skip_below, shift_above),
|
||
ChangeVerdict::Normal
|
||
));
|
||
// the LARGEST ordinary patch, 17.78% -> still normal
|
||
assert!(matches!(
|
||
ch(10_000, 8_222).classify(skip_below, shift_above),
|
||
ChangeVerdict::Normal
|
||
));
|
||
// the SMALLEST toolchain jump, 22.36% -> shift
|
||
assert!(matches!(
|
||
ch(10_000, 7_764).classify(skip_below, shift_above),
|
||
ChangeVerdict::Shift
|
||
));
|
||
// and the largest, 93.8%
|
||
assert!(matches!(
|
||
ch(10_000, 620).classify(skip_below, shift_above),
|
||
ChangeVerdict::Shift
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn function_digests_do_not_depend_on_eh_frame() {
|
||
// The defect this pins: enumerating only `.eh_frame` FDEs samples the statically-linked runtime
|
||
// tail — 8,327 of libserver's ~70,000 functions — and reports "nothing changed" about a build
|
||
// whose gameplay code moved. A synthetic image has no `.eh_frame` at all, so an FDE-only
|
||
// population yields NOTHING here, and `classify-change` would then answer `skip` from an empty
|
||
// sample. call rel32 +0 ; ret ; xor eax,eax ; ret — the call target is a second entry.
|
||
let img = CodeImage::for_test(
|
||
0x1000,
|
||
&[0xE8, 0x00, 0x00, 0x00, 0x00, 0xC3, 0x31, 0xC0, 0xC3],
|
||
);
|
||
assert!(img.eh_frame_functions().is_empty(), "fixture has no FDEs");
|
||
assert!(
|
||
!function_digests(&img).is_empty(),
|
||
"digests must come from the relocation/call-target union too, not FDEs alone"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn skip_below_tolerance_absorbs_small_patches() {
|
||
// raising the tolerance to 1% reclassifies the 0.08% patch as skip, and must not touch a shift
|
||
assert!(matches!(
|
||
ch(10_000, 9_992).classify(0.01, 0.20),
|
||
ChangeVerdict::Skip
|
||
));
|
||
assert!(matches!(
|
||
ch(10_000, 6_600).classify(0.01, 0.20),
|
||
ChangeVerdict::Shift
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn an_empty_new_build_is_not_a_shift() {
|
||
// frac() guards n_new == 0; without that guard this would divide by zero
|
||
assert!(matches!(ch(0, 0).classify(0.0, 0.20), ChangeVerdict::Skip));
|
||
}
|
||
|
||
#[test]
|
||
fn lcg_coin_is_not_periodic() {
|
||
// Guards the LCG coin: returning low bits would make `below(2)` a period-4 sequence, so the
|
||
// call-vs-netvar coin would be effectively constant across consecutive fuzz iterations.
|
||
let mut r = Lcg(0x5137);
|
||
let draws: Vec<usize> = (0..1000).map(|_| r.below(2)).collect();
|
||
assert!(
|
||
draws
|
||
.chunks(4)
|
||
.filter(|c| c.len() == 4)
|
||
.any(|c| c[0] != c[2] || c[1] != c[3]),
|
||
"below(2) still looks 4-periodic"
|
||
);
|
||
let ones = draws.iter().filter(|&&d| d == 1).count();
|
||
assert!(
|
||
(400..=600).contains(&ones),
|
||
"coin badly biased: {ones}/1000 ones"
|
||
);
|
||
}
|
||
}
|