//! 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"
        );
    }
}
