//! 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
//! 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`),
//! `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, 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, whether passed as loose files or unpacked from a `--seed` bundle. All of
/// `mappings/`'s non-contribution content collapses into one release artifact; the loose-flag form stays
/// for dev/verify. The model is NOT bundled here — it is its own (streamed-write) artifact, `--corpus-model`.
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). Sections are optional except
/// the three the derive requires; a missing optional section (e.g. Dota has no `full_names` yet) stays None.
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.
    if let Some(Value::Object(files)) = v.get("contributions") {
        let cdir = work.join("contributions").join(prof.game_key);
        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")?,
    })
}

/// 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
    /// (gamedata + model only, no server). This is 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` (validated in `produce_cmd`). 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>,
    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 → fold model — writing the release set ONCE (`gamedata-<game>.json` +
/// `netvars-<game>.json` [full only] + `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
/// monolith is annotated live in place, the schema is read straight off the process. 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,
        sig_cap,
        version,
        out_dir,
        wait,
        map,
        bots,
    } = a;
    // `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 (2.9 GB 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,
    };
    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 (mut mono, cssharp) = build_gamedata_cmd(
        prof,
        FoldArgs {
            build,
            lib,
            promotable,
            candidates,
            core: &derived.core,
            flagged: &derived.flagged,
            unverified: &derived.unverified,
            sig_cap,
            version,
            full_names,
            extra_offsets,
            extra_sigs,
            source_build: &label_of(target),
        },
    )?;

    // 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);
                }
                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
                );
                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 -> gamedata + model only (no live validate / netvars) ====="
        ),
    }

    // 3. write the release set ONCE.
    let gd_name = format!("gamedata-{token}.json");
    std::fs::write(p(&gd_name), serde_json::to_string_pretty(&mono)?)
        .with_context(|| format!("write {gd_name}"))?;
    let mut artifacts = vec![gd_name];
    if let Some(schema) = &netvars {
        let nv_name = format!("netvars-{token}.json");
        std::fs::write(p(&nv_name), serde_json::to_string_pretty(schema)?)
            .with_context(|| format!("write {nv_name}"))?;
        eprintln!(
            "  typed netvars -> {nv_name}: {} classes, {} typed fields, {} untyped",
            schema.classes.len(),
            schema.meta.typed,
            schema.meta.untyped
        );
        artifacts.push(nv_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.
    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);
    }

    // 5. 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.
fn function_digests(img: &CodeImage) -> HashMap<u64, u32> {
    let mut m: HashMap<u64, u32> = HashMap::new();
    for (start, end) in img.eh_frame_functions() {
        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 .eh_frame functions in one of the builds"
    );
    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)
}

/// 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.
/// 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,
}

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,
    vt_slots: usize,
    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 struct OracleCounts {
    pub checked: u32,
    pub ok: u32,
    pub 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 the standalone `fuzz-live`
/// 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(
    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;
        }
        classes.push(LiveClass {
            vtable_slot0: vt.slot0,
            vt_slots: vt.slots.len(),
            query_methods,
            fields,
        });
    }
    ensure!(
        !classes.is_empty(),
        "no probeable classes (gamedata offsets with RTTI vtables) — is --gamedata right?"
    );
    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())];
            if *slot >= c.vt_slots {
                continue;
            }
            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,
    };

    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 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 (skip the smoke test),
        // 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;
        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()?;
            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]).ok()?;
            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)"
                );
            }
            Some(())
        })();
        if probed.is_none() {
            eprintln!("  CALL test SKIPPED: pawn {pawn:#x} became unreadable (freed mid-oracle?)");
        }
    }

    let live_result = if let Some(gd) = gamedata {
        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)?;
        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 {
            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)
}

/// A launched, ready CS2 bots server the caller owns (must kill).
pub(crate) struct OwnedServer {
    pub(crate) child: std::process::Child,
    pub(crate) pid: u32,
}

/// Launch a vanilla bots-only CS2 deathmatch server (no mod, no human), wait for an alive bot pawn, 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");
    let exe = bindir.join(prof.executable);
    ensure!(
        exe.exists(),
        "{} server executable `{}` not found at {}",
        prof.display_name,
        prof.executable,
        exe.display()
    );
    // 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.
    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()
            );
        }
        if let Ok(live) = live::LiveProcess::attach(pid)
            && let Some(base) = live.base(lib)
        {
            let insts = live.find_instances(ready_vt.slot0 + base, 64);
            let ready = match pawn_health {
                Some(health) => insts
                    .into_iter()
                    .any(|o| live.read_i32(o + health).unwrap_or(0) > 0),
                None => !insts.is_empty(),
            };
            if ready {
                return Ok(OwnedServer { child, pid }); // ready — detach the poll handle, hand off the child
            }
        }
        if std::time::Instant::now() >= deadline {
            let _ = child.kill();
            let _ = child.wait();
            anyhow::bail!(
                "not ready within {wait}s — check {} (server up? map/bots loaded?)",
                logpath.display()
            );
        }
    }
}

/// Validate a generated gamedata json against a LIVE server and write the adjusted (passing) file —
/// the headless CI self-check. Confident-bad entries (a sig that no longer resolves/executes, an
/// offset whose slot isn't code) are dropped; ambiguous ones (base/derived offset, unknown class)
/// are kept but reported so nothing valid is silently lost.
fn validate_live_cmd(
    prof: &GameProfile,
    pid: u32,
    dir: &Path,
    doc: &GdMap,
) -> Result<(GdMap, BTreeMap<String, Option<bool>>)> {
    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 {
        // `render::entry_from_value` is the canonical inverse of the locator shape; a real gamedata locator
        // is sig-XOR-offset, so match signature first, then offset.
        let e = render::entry_from_value(entry);
        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()));
            apply_sig(&mut t, &mut kept, &mut verdicts, name, entry, v);
        } else if let Some(off) = e.offset {
            let class = class_of(name);
            let v = validate_offset(&idx, class, off, &bases, &live);
            apply_offset(&mut t, &mut kept, &mut verdicts, name, entry, off, class, v);
        } else {
            // no locator at all — nothing the oracle can check; keep it but never claim validation.
            kept.insert(name.clone(), entry.clone());
            verdicts.insert(name.clone(), None);
        }
    }

    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);
    }
    Ok((kept, verdicts))
}

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);
    ensure!(
        !classes.is_empty(),
        "offline schema derivation found no classes in {lib}"
    );

    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>,
}

fn apply_sig(
    t: &mut ValTally,
    kept: &mut GdMap,
    verdicts: &mut BTreeMap<String, Option<bool>>,
    name: &str,
    entry: &serde_json::Value,
    v: SigVerdict,
) {
    // `keep` records the entry AND its honest validation verdict: Some(true) = live-confirmed, None = kept but
    // the oracle could not check it (so the artifact must NOT claim validation). A dropped verdict is left
    // absent (annotate_validation defaults absent -> Some(false)).
    let keep = |k: &mut GdMap, verd: &mut BTreeMap<String, Option<bool>>, val: Option<bool>| {
        k.insert(name.to_string(), entry.clone());
        verd.insert(name.to_string(), val);
    };
    match v {
        SigVerdict::Ok => {
            t.sig_ok += 1;
            keep(kept, verdicts, Some(true));
        }
        SigVerdict::Hooked => {
            t.sig_ok += 1;
            t.sig_hooked += 1;
            keep(kept, verdicts, Some(true)); // sig IS valid; the hook is a runtime overlay, not a mismatch
            t.flagged.push(format!(
                "{name} — sig valid; function is HOOKED live (a mod detoured it)"
            ));
        }
        SigVerdict::Unvalidatable(why) => {
            t.sig_unval += 1;
            keep(kept, verdicts, None); // lib not mapped this run -> kept but UNVERIFIED, never claim Some(true)
            t.flagged
                .push(format!("{name} — sig kept, unvalidated: {why}"));
        }
        SigVerdict::Fail(why) => {
            t.sig_fail += 1;
            t.dropped.push(format!("{name} — sig: {why}"));
        }
    }
}

fn apply_offset(
    t: &mut ValTally,
    kept: &mut GdMap,
    verdicts: &mut BTreeMap<String, Option<bool>>,
    name: &str,
    entry: &serde_json::Value,
    off: i64,
    class: &str,
    v: OffVerdict,
) {
    let keep = |k: &mut GdMap, verd: &mut BTreeMap<String, Option<bool>>, val: Option<bool>| {
        k.insert(name.to_string(), entry.clone());
        verd.insert(name.to_string(), val);
    };
    match v {
        OffVerdict::Live => {
            t.off_live += 1;
            keep(kept, verdicts, Some(true));
        }
        OffVerdict::LiveViaSub(d) => {
            t.off_live += 1;
            t.off_sub += 1;
            keep(kept, verdicts, Some(true)); // the slot IS executable on a subclass vtable — validated
            t.flagged.push(format!(
                "{name} — offset {off} valid on subclass {d} (entry names a base class)"
            ));
        }
        OffVerdict::NotCode => {
            t.off_bad += 1;
            t.dropped
                .push(format!("{name} — offset {off}: slot not executable"));
        }
        OffVerdict::Oob => {
            t.off_oob += 1;
            keep(kept, verdicts, Some(false)); // offset exceeds the vtable = likely WRONG -> flag false (render drops it)
            t.flagged.push(format!(
                "{name} — offset {off} exceeds {class} and its subclasses' vtables"
            ));
        }
        OffVerdict::Unknown => {
            t.off_unknown += 1;
            keep(kept, verdicts, None); // not a vtable class -> the oracle can't check it, kept but UNVERIFIED
            t.flagged.push(format!(
                "{name} — {class} is not a vtable class (engine special / member offset)"
            ));
        }
    }
}

#[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 and the calibration behind the defaults is recorded
    // in the CLI help (339 CS2 build pairs: 311 code-identical, real patches <=0.08% changed, the two
    // toolchain jumps 34% and 53%). Pin those four cases so a threshold edit has to face them.
    fn ch(n_new: u32, common: u32) -> DigestChange {
        DigestChange {
            n_prev: n_new,
            n_new,
            common,
        }
    }

    #[test]
    fn classify_defaults_match_the_documented_calibration() {
        let (skip_below, shift_above) = (0.0, 0.20);
        // code-identical build -> skip (the common case: 311 of 339 pairs)
        assert!(matches!(
            ch(10_000, 10_000).classify(skip_below, shift_above),
            ChangeVerdict::Skip
        ));
        // an ordinary patch: 8 of 10_000 = 0.08% -> normal, NOT skip, at the default 0 tolerance
        assert!(matches!(
            ch(10_000, 9_992).classify(skip_below, shift_above),
            ChangeVerdict::Normal
        ));
        // the two observed toolchain jumps, 34% and 53% -> shift
        assert!(matches!(
            ch(10_000, 6_600).classify(skip_below, shift_above),
            ChangeVerdict::Shift
        ));
        assert!(matches!(
            ch(10_000, 4_700).classify(skip_below, shift_above),
            ChangeVerdict::Shift
        ));
    }

    #[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"
        );
    }
}
