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