//! The canonical derived-gamedata model — the single in-memory representation the derivation produces
//! and the emitters consume. Deliberately format-agnostic (no serde_json shapes here): `render::*`
//! turns it into CSSharp JSON, Metamod VDF, etc. Keeping the shape here (not smeared through `json!`
//! call sites) is what lets one derivation feed every output format and a standalone generator.

use std::collections::BTreeMap;

/// A byte-pattern signature located in a specific library.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Sig {
    pub library: String, // "server", "engine2", … (the module the pattern scans)
    pub linux: String,   // space-hex pattern with `?` wildcards, e.g. "55 48 89 ? E5"
}

/// One gamedata function: a vtable-method offset, a scan signature, or (rarely) both — plus, where we
/// have them, string ANCHORS that locate the same function a different way.
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Entry {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub signature: Option<Sig>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub offset: Option<i64>, // vtable slot index (or a carried member offset)
    /// For a vtable-OFFSET locator: the class whose vtable the slot was measured on.
    ///
    /// Part of the locator, not decoration — a slot index alone locates nothing, since it is only meaningful
    /// relative to a particular class's vtable.
    ///
    /// It is the entry name's own class, stated explicitly: the deriver keys its slot timelines and
    /// alignment hops by the name's class and chains through that one, so "the class the offset was
    /// chained through" and "the class in the name" are one fact rather than two. Directly folded offsets
    /// (the multilib ground-truth path) carry no class at all and leave the consumer to split the name.
    /// So a reader may treat this as a convenience copy — never as a second, independent attribution.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub class: Option<String>,
    /// Distinctive string literals this function references, each unique to it within its library.
    ///
    /// NOT a locator competing with the signature and the offset — a supplement with a DIFFERENT failure
    /// mode. A byte signature is a snapshot of one build's codegen; a string survives a recompile that
    /// moves instructions. So a consumer that can resolve anchors (ModSharp's `refs.strings`) has a
    /// locator that keeps working across the window between Valve shipping a build and us republishing,
    /// which is exactly when a byte pattern is most likely to have drifted.
    ///
    /// Emitted alongside the signature, never instead of it: the two are independent, and a consumer
    /// choosing between them is better served by having both than by our picking one.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub anchors: Vec<String>,
}

impl Entry {
    /// A signature-only locator. A convenience for the common shape, NOT an invariant — an entry may carry
    /// a signature and an offset at once (the struct doc says so, and real `core` entries do), so anything
    /// judging an entry must check every locator it holds rather than the first one it finds.
    pub fn signature(library: impl Into<String>, linux: impl Into<String>) -> Entry {
        Entry {
            signature: Some(Sig {
                library: library.into(),
                linux: linux.into(),
            }),
            offset: None,
            class: None,
            anchors: Vec::new(),
        }
    }

    /// A vtable-offset-only locator.
    pub fn offset(linux: i64) -> Entry {
        Entry {
            signature: None,
            offset: Some(linux),
            class: None,
            anchors: Vec::new(),
        }
    }
}

/// The default output game-key when none is set — CS2's Steam content-dir token. Games-keyed emitters
/// (Metamod/Plugify) fall back to this so an older model JSON (no `game_key`) still renders as CS2.
fn default_game_key() -> String {
    "csgo".to_string()
}

/// The derived gamedata for one build: function name -> entry. BTreeMap so iteration/output is
/// deterministically key-sorted, matching serde_json's Map key ordering byte-for-byte.
///
/// Serializing this IS the canonical "model JSON" — the single per-build artifact the deriver
/// publishes and the standalone `source2rosetta-gen` reads back to produce every framework's format.
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct Gamedata {
    pub entries: BTreeMap<String, Entry>,
    /// The game token the game-keyed emitters wrap output in (Metamod `Games { <game_key> {..} }`,
    /// Plugify `{ "<game_key>": {..} }`). Persisted here because `source2rosetta-gen` renders from the model
    /// JSON alone, with no access to the deriver's `GameProfile`. Defaults to CS2's `csgo`.
    #[serde(default = "default_game_key")]
    pub game_key: String,
}

impl Default for Gamedata {
    fn default() -> Self {
        Self {
            entries: BTreeMap::new(),
            game_key: default_game_key(),
        }
    }
}

/// Why the derivation could not produce a shipped locator — the closed domain the `unresolved` tier reports.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum FlagReason {
    /// A catalogued signature that no longer resolves uniquely / wasn't recovered in the target.
    SigDrifted,
    /// A vtable offset whose recency-weighted vote fell below the confidence bar.
    OffsetLowConf,
    /// No chainable anchor / no reference history at all.
    Unresolved,
    /// The sig SHIPPED, but its ABI prototype shape drifted from the model consensus (review the prototype).
    AbiDrift,
    /// A signature DID resolve, and the address it resolved to is provably a different function — the
    /// binary names it something else and the code operates on another class. Distinct from `SigDrifted`
    /// because it calls for the opposite response: a drifted entry may simply reappear next build, while
    /// this one says the catalogue's own signature is finding a decoy and the entry needs a new locator.
    NameContradicted,
}

impl FlagReason {
    /// The kebab id — the same string the `kebab-case` serialization emits, for callers that carry the
    /// reason across to the monolith's `Unresolved.reason` (a `String`, kept stable for byte-reproducibility).
    pub fn as_str(self) -> &'static str {
        match self {
            FlagReason::SigDrifted => "sig-drifted",
            FlagReason::OffsetLowConf => "offset-low-conf",
            FlagReason::Unresolved => "unresolved",
            FlagReason::AbiDrift => "abi-drift",
            FlagReason::NameContradicted => "name-contradicted",
        }
    }
}

/// A catalogue entry the derivation could NOT confidently produce. Emitted as a first-class sidecar
/// (never guessed into the gamedata — safety > recall) so it is both reviewable and machine-readable.
#[derive(Debug, Clone, serde::Serialize)]
pub struct Flagged {
    pub name: String,
    pub reason: FlagReason,
    /// The signal we do have: the carried value, vote confidence, "no reference", …
    pub detail: String,
}

impl Flagged {
    pub fn new(name: impl Into<String>, reason: FlagReason, detail: impl Into<String>) -> Self {
        Self {
            name: name.into(),
            reason,
            detail: detail.into(),
        }
    }
}

impl Gamedata {
    pub fn set_signature(
        &mut self,
        name: impl Into<String>,
        library: impl Into<String>,
        linux: impl Into<String>,
    ) {
        self.entries.entry(name.into()).or_default().signature = Some(Sig {
            library: library.into(),
            linux: linux.into(),
        });
    }

    pub fn set_offset(&mut self, name: impl Into<String>, linux: i64) {
        self.entries.entry(name.into()).or_default().offset = Some(linux);
    }

    /// Record the class a vtable-offset locator is relative to.
    pub fn set_class(&mut self, name: impl Into<String>, class: impl Into<String>) {
        self.entries.entry(name.into()).or_default().class = Some(class.into());
    }

    /// Attach string anchors to `name`, creating the entry if the derivation reached it by no other route.
    ///
    /// Deduplicated and order-preserving: the catalogue can carry the same anchor twice across variants,
    /// and the emitted list is part of a byte-reproducible artifact, so it must not depend on how many
    /// times a source repeated itself.
    pub fn add_anchors<S: Into<String>>(
        &mut self,
        name: impl Into<String>,
        anchors: impl IntoIterator<Item = S>,
    ) {
        let e = self.entries.entry(name.into()).or_default();
        for a in anchors {
            let a = a.into();
            if !a.is_empty() && !e.anchors.contains(&a) {
                e.anchors.push(a);
            }
        }
    }

    pub fn len(&self) -> usize {
        self.entries.len()
    }

    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }
}

// ===========================================================================================
// The tiered catalogue — WHERE each named function is. Four confidence tiers with provenance
// + live-validation folded inline. It is the locator half of what `merge` folds into the shipped
// `rosetta-<game>.json`, where each entry becomes one [`FunctionRecord`].
// A `MonoEntry` EMBEDS `Entry`, so the locator shape stays
// single-sourced on `render::locator_value` and never diverges. Lib-agnostic: an entry carries its
// `library` in the signature locator, so the monolith spans every derived library, not just libserver.
// ===========================================================================================

/// A monolith entry's confidence tier — its finer label within a section, serialized as kebab strings
/// (`core`, `valve-table`, `self-named`, `dict-exact`, `contextual`, `corroborated`, `high`, `medium`,
/// `low`). Declared most-confident first.
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Tier {
    Core,
    /// Named by a table Valve compiled into the binary being derived — today the entity-IO datadesc,
    /// whose records pair a handler name with the handler itself. The only naming source that is neither
    /// inferred nor transferred from another build, so it outranks every tier below it: the binary is
    /// vouching for its own function names. (It is still not [`Tier::Core`], which additionally means
    /// cross-build history — fingerprint verification and drift detection — that one build cannot supply.
    /// The Pulse binding table names far more functions but does not LOCATE them; it ships as its own
    /// registry, see [`Bindings`].)
    ValveTable,
    SelfNamed,
    /// Dictionary-corroborated by the FOLD (an exact hit in the harvested name catalogue).
    DictExact,
    Contextual,
    /// Dictionary-corroborated by the EXPERIMENTAL band, and also the label the macOS ground-truth
    /// transfer carries. Kept distinct from [`Tier::DictExact`] rather than merged: the two are produced
    /// by different paths, and folding them together would additionally conflate ground-truth symbols
    /// with dictionary guesses. Anything counting "corroborated" for display must count BOTH — see
    /// [`Tier::is_dict_corroborated`].
    Corroborated,
    High,
    Medium,
    Low,
}

impl Tier {
    /// Is this tier a dictionary/ground-truth corroboration, under either of its two labels?
    pub fn is_dict_corroborated(self) -> bool {
        matches!(self, Tier::DictExact | Tier::Corroborated)
    }
}

/// Per-entry provenance, folded inline. Optional by tier: a `core` entry carries almost nothing, an
/// `experimental` guess carries the full grading.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct Provenance {
    pub tier: Tier,
    /// Raw address in THIS build — a debugging/trace anchor (the monolith is per-build, so it's coherent).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub addr: Option<String>,
    /// The confidence LABEL as the harvest records it — `"high"` / `"medium"` / `"low"` — a separate axis
    /// from `tier` (a self-named entry can still be low-confidence). A string, not a number.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub confidence: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub self_named: Option<bool>,
    /// Return is struct-by-value → unsafe to naive-call (the `RetClass::ByValue` flag).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub by_value: Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub ret_class: Option<String>,
    /// `"catalogue"` | `"source2rosetta-nameext"` | `"contribution:<date>"` | …
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub source: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub rtti_class: Option<String>,
    /// high_confidence tier only — the naming rationale.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub rationale: Option<String>,
    /// experimental tier only — how the dictionary corroborated the guess (`"exact"` / `"bare"` / `"none"`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub corroboration: Option<String>,
    /// experimental tier only — the same name was guessed at more than one address.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub collision: Option<bool>,
    /// experimental tier only — protobuf/serializer/foreign plumbing (flagged, kept for completeness).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub dead_weight: Option<bool>,
    /// core only — the target's ABI prototype-shape differs from the model's consensus (`"target [..] vs
    /// history [..]"`). The signature still ships (a drifted arg-list is a loader-hook seam a byte-sig can't
    /// see, not a wrong locator), but a consumer that ptrace-calls it should re-check the prototype.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub abi_drift: Option<String>,
}

impl Tier {
    /// The tier's kebab id — the same string the `kebab-case` serialization emits, for a deriver that needs
    /// it as a plain `&str` (e.g. a count-by-tier tally) without going through serde.
    pub fn as_str(self) -> &'static str {
        match self {
            Tier::Core => "core",
            Tier::ValveTable => "valve-table",
            Tier::SelfNamed => "self-named",
            Tier::DictExact => "dict-exact",
            Tier::Contextual => "contextual",
            Tier::Corroborated => "corroborated",
            Tier::High => "high",
            Tier::Medium => "medium",
            Tier::Low => "low",
        }
    }

    /// Parse a tier from its kebab id (the inverse of the `kebab-case` serialization) — so a deriver holding
    /// a tier as a computed string can lift it to the typed enum without a serde_json round-trip. `None` for
    /// an unrecognised id (the caller decides whether that is a hard error).
    pub fn from_id(s: &str) -> Option<Tier> {
        Some(match s {
            "core" => Tier::Core,
            "valve-table" => Tier::ValveTable,
            "self-named" => Tier::SelfNamed,
            "dict-exact" => Tier::DictExact,
            "contextual" => Tier::Contextual,
            "corroborated" => Tier::Corroborated,
            "high" => Tier::High,
            "medium" => Tier::Medium,
            "low" => Tier::Low,
            _ => return None,
        })
    }
}

impl Provenance {
    /// A provenance with only its tier set (all optional fields `None`) — the base for `core` entries and
    /// the start point for functional-update construction (`Provenance { source: …, ..with_tier(t) }`).
    pub fn with_tier(tier: Tier) -> Self {
        Self {
            tier,
            addr: None,
            confidence: None,
            self_named: None,
            by_value: None,
            ret_class: None,
            source: None,
            rtti_class: None,
            rationale: None,
            corroboration: None,
            collision: None,
            dead_weight: None,
            abi_drift: None,
        }
    }
}

/// One monolith function: a locator (the `signature`/`offset` model shape, flattened in from [`Entry`] so
/// the shape is single-sourced) plus its provenance and live-validation verdict.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct MonoEntry {
    #[serde(flatten)]
    pub locator: Entry,
    /// The argument footprint read out of THIS build's machine code — see [`AbiShape`]. Absent when the
    /// function's address wasn't resolvable offline.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub abi: Option<AbiShape>,
    pub provenance: Provenance,
    /// Live-validation verdict: `Some(true)` passed, `Some(false)` dropped confident-bad, `None` unvalidated.
    #[serde(default)]
    pub validated: Option<bool>,
    /// The OTHER shipped names that locate this same function — key-sorted, never including this entry's own
    /// name, and empty for the ~95% of entries that are the only name for their target.
    ///
    /// Several names on one function is normal and not a defect: the catalogue is assembled from independent
    /// sources that spell the same function differently (`CreateEntityByName`, `UTIL::CreateEntityByName`
    /// and `CGameEntitySystem::CreateEntityByName` are one address), and dropping all but one would discard
    /// whichever spelling a given consumer's existing code already uses. What was missing is that the
    /// artifact never SAID so, which left a reader unable to tell an alias from two genuinely different
    /// functions — and left anything generating per-function documentation writing several unrelated
    /// accounts of one target.
    ///
    /// **Scope: `core` + `high_confidence`, across the two tiers rather than within each.** `experimental` is
    /// excluded because its names are unverified guesses, so a shared target there is not evidence of a
    /// shared meaning — that band states the converse relation (one name guessed at several addresses)
    /// through `provenance.collision`.
    ///
    /// **A bare-slot entry is never grouped**, and that is a real gap rather than an absence of aliases: an
    /// `offset` with no `class` names no vtable, so two of them carrying slot 3 are not evidence of anything.
    /// Grouping them would put every unbound slot-3 entry in one bucket — 1,080 CS2 names in 70 fictitious
    /// groups, measured. Only class-bound offsets and signatures are grouped.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub aliases: Vec<String>,
}

/// A function's SysV-AMD64 argument footprint, read out of the target binary rather than declared: how many
/// integer and float registers it takes as inputs, whether arguments also spill to the stack, and how it
/// returns. This is what makes a *declared* prototype checkable — a declaration whose arity contradicts the
/// footprint does not describe this build, and calling through it would load the wrong registers.
///
/// A lower bound, never an over-count (a forwarding thunk reads no argument register of its own), so a
/// disagreement is worth review rather than an automatic rejection.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct AbiShape {
    /// Integer/pointer arguments, including an implicit `this`. Caps at 6 — the SysV register budget.
    pub int: u8,
    /// Floating arguments (XMM0..7).
    pub float: u8,
    /// Arguments also arrive on the stack: the real arity exceeds the register budget, so a caller filling
    /// only registers is wrong.
    #[serde(default, skip_serializing_if = "is_false")]
    pub stack: bool,
    /// Return class token: `ret=void` | `ret=int` | `ret=float` | `ret=byval` | `ret=?` (undetermined).
    /// `ret=byval` is the sret case — UNSAFE to blind-call, since the caller must pass an output buffer.
    pub ret: String,
}

fn is_false(b: &bool) -> bool {
    !*b
}

/// A catalogued function the derivation could not confidently produce — kept in-file (never a shipped
/// locator) so the monolith is the complete catalogue picture.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct Unresolved {
    pub reason: String, // "sig-drifted" | "offset-low-conf" | "unresolved" | …
    pub detail: String,
}

/// Entry counts per section — a struct (not a map) so it serializes in this logical order, deterministically.
#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct Counts {
    pub core: usize,
    pub high_confidence: usize,
    pub experimental: usize,
    pub unresolved: usize,
}

/// The monolith's intrinsic release identity. NO wall-clock field — volatile release metadata (`produced_at`,
/// `status`, urls, sha256, `based_on`) lives in the per-buildid manifest, so the monolith is fully
/// byte-reproducible.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct MonoMeta {
    pub game_key: String,
    pub game: String, // display name
    pub source_build: String,
    /// `<game>-<buildid>-<patch>` — the buildid is embedded here; the per-buildid MANIFEST carries it as its
    /// own field (volatile metadata is kept OUT of this byte-reproducible monolith).
    pub version: String,
    pub counts: Counts,
    /// How many distinct functions carry more than one shipped name, and how many names that accounts for
    /// — the two halves of [`MonoEntry::aliases`] seen from the release's side. Counted over
    /// `core` + `high_confidence` together, since a group routinely spans the two tiers.
    ///
    /// Worth reading before treating the tier counts as a function count: on CS2 roughly 5% of the resolved
    /// surface is several names on one target, so `counts.core + counts.high_confidence` over-counts
    /// FUNCTIONS by about that much while being exactly right about NAMES, which is what it says.
    #[serde(default)]
    pub alias_groups: usize,
    #[serde(default)]
    pub aliased_names: usize,
}

/// The full derived gamedata for one build — four confidence tiers, each a key-sorted map. [`merge`]
/// flattens it into the shipped artifact's `functions`, where the tier a name came from rides on the
/// record instead of deciding which map it lives in.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct Monolith {
    pub meta: MonoMeta,
    pub core: BTreeMap<String, MonoEntry>,
    pub high_confidence: BTreeMap<String, MonoEntry>,
    pub experimental: BTreeMap<String, MonoEntry>,
    pub unresolved: BTreeMap<String, Unresolved>,
}

/// Which of the monolith's three SHIPPABLE tiers a render includes — cumulative, most-confident first
/// (`unresolved` is never rendered; it has no locator). The `--tier` arg of `source2rosetta-gen`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TierSelect {
    /// `core` only — the guaranteed, live-validated set.
    Core,
    /// core + high_confidence — adds the promoted name-extrapolations.
    HighConfidence,
    /// core + high_confidence + experimental — every locatable guess.
    Experimental,
}

impl TierSelect {
    /// Parse the `--tier` id (the monolith tier names, plus a couple of intuitive aliases).
    pub fn from_id(s: &str) -> Option<TierSelect> {
        match s {
            "core" => Some(TierSelect::Core),
            "high_confidence" | "high-confidence" | "stable" => Some(TierSelect::HighConfidence),
            "experimental" | "full" => Some(TierSelect::Experimental),
            _ => None,
        }
    }
}

/// Every `--tier` id `from_id` accepts (canonical names) — for help text.
pub const TIER_IDS: &[&str] = &["core", "high_confidence", "experimental"];

impl Monolith {
    /// Flatten the tiers up to `select` into one `name -> Entry` map — the input the flat framework emitters
    /// (metamod/modsharp/swiftly/plugify) consume. Drops confident-bad entries (`validated == Some(false)`);
    /// `unresolved` is never included (no locator); a more-confident tier wins a name collision.
    pub fn select(&self, select: TierSelect) -> Gamedata {
        let mut gd = Gamedata {
            entries: BTreeMap::new(),
            game_key: self.meta.game_key.clone(),
        };
        let mut add = |m: &BTreeMap<String, MonoEntry>| {
            for (name, e) in m {
                if e.validated == Some(false) {
                    continue;
                }
                gd.entries
                    .entry(name.clone())
                    .or_insert_with(|| e.locator.clone());
            }
        };
        add(&self.core);
        if select != TierSelect::Core {
            add(&self.high_confidence);
        }
        if select == TierSelect::Experimental {
            add(&self.experimental);
        }
        gd
    }
}

// ===========================================================================================
// The binding registry — what the binary DECLARES about its own callable surface, as opposed to what the derivation infers about it: Valve registers every Pulse
// binding and entity-IO input with a name, author-facing documentation, and call metadata, and this is
// that data read back out. Kept OUT of the monolith on purpose — the monolith answers "where is this
// function", this answers "what may I do with it", and only the first belongs in a gamedata file.
// ===========================================================================================

/// How a Pulse binding is invoked — the receiver question, which decides whether a caller needs an
/// entity at all. Mutually exclusive by construction (the two flag bytes are never set together).
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum BindingKind {
    /// A free function on a Pulse library class (`CPulseMathlib::Sin`) — no receiver.
    Library,
    /// A method on an entity API (`CBaseEntityAPI::GetAbsOrigin`) — needs an instance receiver.
    Instance,
    /// A Pulse cell's own entry point (`CPulseCell_Step_DebugLog::Run`) — invoked by the VM as it walks
    /// a graph, not called by a graph author.
    Cell,
}

/// The call metadata Valve records for a binding — a typed graph VM cannot register a binding without
/// knowing how it may be invoked, so this is the engine's own policy, read back rather than reasoned out.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct CallPolicy {
    pub kind: BindingKind,
    /// The binding writes state. The const-correctness axis: a caller that only observes can be run
    /// where a mutation may not be.
    pub mutates: bool,
    /// The binding may suspend the calling cursor instead of completing within the frame.
    pub blocking: bool,
    /// The two metadata words exactly as read, so a consumer can re-derive meaning if a later build
    /// repurposes a bit rather than silently inheriting this decoding.
    pub raw: [u32; 2],
}

/// One Pulse binding: where it is, what Valve calls it, and how it may be invoked.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct Binding {
    /// The module registering it — `server`, `pulse_system`, …
    pub library: String,
    /// The author-facing label ("Get Abs Origin").
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub display: Option<String>,
    /// The author-facing documentation ("The entity origin (absolute).").
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    pub policy: CallPolicy,
    /// The binding's declared parameters, in order, recovered from the descriptor accessor's own
    /// initializer. Pulse is a TYPED graph VM, so this is the engine's own statement of how the
    /// binding is called — not a transfer from another game and not a 2018-era declaration.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub params: Vec<PulseParam>,
    /// The values it hands back. Pulse models returns as named out-parameters, so this is a LIST:
    /// usually one `retval`, empty for a void binding, occasionally several.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub returns: Vec<PulseParam>,
    /// Whether the signature above was recovered at all. An empty `params` on an untyped binding means
    /// "not read"; on a typed one it means "takes nothing", and only this field separates them.
    pub typed: bool,
    /// Address of the binding's DESCRIPTOR ACCESSOR in this build — a lazy-init singleton returning the
    /// static descriptor, not the bound function. It is the anchor a runtime walks to reach the
    /// descriptor; it is NOT a locator for the named method, and no shipped gamedata entry points at it.
    /// For an address that IS callable, see [`Binding::shim`].
    pub descriptor: String,
    /// Address of the binding's INVOCATION SHIM in this build — the record's third code pointer, and
    /// unlike `descriptor` a real entry point. One per binding, never shared.
    ///
    /// Calling it dispatches through Valve's own marshalling, which honours the DECLARED parameter types
    /// in `params`: a value written into the argument blob is consumed according to its `PulseValueType_t`,
    /// so a caller cannot smuggle a mistyped argument past it. Absent when the record's slot holds no
    /// executable code.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub shim: Option<String>,
    /// How to call [`Binding::shim`], and what a host must supply. Absent when there is no shim.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub call: Option<ShimCall>,
}

/// The invocation shim's calling contract: fixed across every binding, with a per-binding statement of
/// which slots that particular shim reads.
///
/// The signature is **seven integer arguments returning int**. Slot 5 (`r8`) is an array of POINTERS to the
/// argument values, element *k* at `+8+8k`. Slot 7 (the first stack slot) is the output sink. Slot 4
/// (`rcx`) is a Pulse host-service context, which is VM-owned. The return is `0` on dispatch and `-2` when
/// an entity-handle argument fails to resolve.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ShimCall {
    /// What a host must supply beyond the argument array, decoded from `reads`:
    ///
    /// * `args-only` — nothing else. The remaining slots may be null; **validated by calling every
    ///   eligible binding in both games.** This is the callable tier.
    /// * `output-sink` — it returns a value, so it writes through a register-file object a host does not
    ///   have. Read the state through the `schema` section instead; the Pulse getters are redundant
    ///   with schema fields.
    /// * `pulse-context` — needs a live `CPulseExecCursor` / graph instance. Not host-callable.
    /// * `other-slots` — reads an argument slot whose role is not established (the `CPulseCell_*`
    ///   family, which are graph NODE implementations rather than API bindings). Not host-callable.
    pub needs: String,
    /// The argument slots this shim was measured to read, named in SysV order — the raw fact `needs` is
    /// decoded from, kept beside it so a build that changes the contract can be re-read rather than
    /// silently mis-labelled. The same rule `flags_raw` follows.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub reads: Vec<String>,
}

/// One Pulse parameter or return value, as the binding declares it.
///
/// Defined here rather than beside the reader so the deriver-free core crate can carry it: `gen` emits
/// these, and nothing about the shape depends on how it was read out of the binary.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct PulseParam {
    /// The author-facing parameter name — `_Target` for the receiver, `retval` for a return.
    pub name: String,
    /// The `PulseValueType_t` enumerator, as the binary states it. Join it to the `enums` section of
    /// the `schema` section's `enums` for the spelling; the raw value is kept because that is the fact.
    #[serde(rename = "type")]
    pub ty: i32,
    /// The schema type the value refers to, where the binding NAMES one — which enum a
    /// `PVAL_SCHEMA_ENUM` is, which struct an opaque handle wraps. Absent for the self-describing
    /// types, and absent for a `PVAL_EHANDLE`'s entity class, which the initializer does not state —
    /// see `entity_class` for where that comes from instead.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub type_name: Option<String>,
    /// The entity class behind a `PVAL_EHANDLE` — `func_mover`, `basemodelentity`. NOT read from the
    /// initializer, which never states it: it is Valve's own naming (`mappings/ehandle-classes.json`,
    /// harvested from the published metadata) propagated across the parameters this build proves are
    /// the same type. See [`PulseParam::type_token`] for what proves it.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub entity_class: Option<String>,
    /// An opaque per-build token identifying the parameter's CONCRETE type: the address of that type's
    /// destructor, which the initializer stores beside the name. Two parameters carry the same token
    /// exactly when they are the same type — including the entity class behind a `PVAL_EHANDLE`, which
    /// nothing else in the binary distinguishes.
    ///
    /// NOT serialized, deliberately. An address is meaningless outside the build it was read from, so
    /// shipping it would invite a consumer to key on something that moves every release. It exists to
    /// carry the grouping from the reader to the stage that names it, and no further.
    #[serde(skip)]
    pub type_token: u64,
}

/// One entity-IO input handler: the name a map fires, and the C++ method that answers it.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct EntityInput {
    /// The input name entity IO addresses — `Kill`, `Enable`, `SetSpeed`.
    pub input: String,
    /// The class that owns this handler, recovered from the FIELD descriptors sharing its datadesc
    /// array: a field is a `(member, offset)` pair the SchemaSystem states independently, so the class
    /// whose schema contains every pair in the array owns it. Absent where the array's fingerprint fit
    /// several classes or none — `InputEnable` is a distinct handler on 48 classes and guessing between
    /// them is the thing this exists to stop.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub class: Option<String>,
    /// The C++ handler — `InputKill`. NOT class-qualified: the record carries no owning class, which is
    /// also why the same handler name legitimately appears at many addresses here.
    pub handler: String,
    pub library: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub abi: Option<AbiShape>,
    pub addr: String,
}

/// One registered console command: the name a server operator or a mod types, and the function that
/// answers it. Unlike the Pulse registry this carries a real locator — the handler comes from the same
/// registration call as the name, not from a descriptor accessor beside it.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct ConsoleCommand {
    /// The console-facing name, exactly as Valve compiled it — `bot_add`, `+bugvoice`.
    pub name: String,
    pub library: String,
    /// Valve's own help text; absent when the registration passes none.
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub description: String,
    /// The flag bits whose meaning is measured against Valve's published dump.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub flags: Vec<String>,
    /// The raw flags word, kept beside the decoding so a build that repurposes a bit can be re-read
    /// instead of silently mis-labelled — the same rule the Pulse policy word follows.
    pub flags_raw: String,
    /// How the registration passed its callback: `direct`, `interface` or `member`. Says how much
    /// indirection produced the address, which is the honest measure of how far this is from the
    /// instruction stream.
    pub form: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub abi: Option<AbiShape>,
    pub addr: String,
}

/// One ConVar the module registers — the configuration half of the console surface.
///
/// Emitted for the METADATA, not as a locator: a consumer finds a convar by name at runtime
/// (`ICvar::FindConVar`) with no gamedata at all, so the name alone would add nothing. The flags are the
/// payload — `cheat`, `replicated`, `release` are engine-DECLARED authority, and a host deciding what a
/// module may change is better served by what the engine says than by a hand-maintained allowlist.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct ConVar {
    pub name: String,
    pub library: String,
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub description: String,
    /// FCVAR bits with a measured meaning — the same space console commands use.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub flags: Vec<String>,
    /// The raw flags word. Empty when this registrar had no identifiable flags argument, which is honest
    /// about the gap rather than reporting a zero that would read as "no flags set".
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub flags_raw: String,
    /// Address of the ConVar object. It lives in `.bss`, so it holds nothing on disk — it is the anchor a
    /// runtime walks to the live value, and what tells two registrations of one name apart.
    pub addr: String,
}

/// One entity-IO output: an event an entity fires, and where its subscriber list lives on the instance.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct EntityOutput {
    /// The entity-IO name a map or a mod wires to, e.g. `OnStartTouch`.
    pub output: String,
    /// The member holding it, e.g. `m_OnStartTouch` — usually the name with `m_` prepended, but not
    /// reliably (`BombExplode` lives on `m_OnBombExplode`), so both ship.
    pub member: String,
    pub library: String,
    /// Byte offset of the member within its entity — an output is data, not a function, so this is the
    /// locator. (`CEntityIOOutput` is 24 bytes; see the `types` section of the schema artifact.)
    pub offset: u32,
    /// The class the member lives on, recovered by joining `(member, offset)` against the SchemaSystem.
    /// Without it an offset is unusable wherever a name repeats: `OnBreak` exists at three different
    /// offsets on three different classes, and reading the wrong one runs ~1 KB past the intended member
    /// on a live entity. `None` only when the schema does not describe the member.
    ///
    /// NB the member's TYPE is not carried here: the offline schema reader recovers names and offsets but
    /// not types (those are runtime-resolved), and a handful of outputs are not the plain 24-byte
    /// `CEntityIOOutput` — `CLogicCase::m_OnCase` is `CEntityIOOutput[32]`. Join `class` + `member`
    /// against the `schema` section for the type before striding one.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub class: Option<String>,
}

/// The declared callable surface for one build. [`merge`] folds the function-keyed parts of it onto
/// the functions they describe and keeps the rest under [`Surfaces`].
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct Bindings {
    pub meta: BindingsMeta,
    /// Pulse bindings, keyed by their fully-qualified `Class::Method`.
    pub pulse: BTreeMap<String, Binding>,
    /// Entity-IO inputs, as a LIST: the handler name is not unique (one `InputEnable` per class), so
    /// there is no honest key to map them by.
    pub entity_inputs: Vec<EntityInput>,
    /// Entity-IO outputs — the events an entity fires, as a LIST for the same reason as the inputs.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub entity_outputs: Vec<EntityOutput>,
    /// Map classname -> the C++ class it constructs (`func_door` -> `CBaseDoor`). The join between the
    /// vocabulary a level designer writes and the classes the `schema` section describes. No addresses:
    /// the factory record binds names to names, not to a function.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub entity_classes: BTreeMap<String, String>,
    /// Console commands, as a LIST: a handful of names are registered by more than one library, so
    /// there is no honest key to map them by either.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub commands: Vec<ConsoleCommand>,
    /// ConVars, a LIST for the same reason: one name can be registered by more than one library.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub convars: Vec<ConVar>,
    /// VScript bindings — the surface Valve exposes to Lua, keyed by the SCRIPT-facing name a content
    /// author types. A LIST rather than a map because a name is only unique per class, and the owning
    /// class is not recoverable offline.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub vscript: Vec<VScriptBinding>,
}

/// One function Valve exposes to the script VM.
///
/// The fourth surface the binary documents about itself, and the only one that pairs a script-facing
/// name with a C++ name, an English description AND a return type in one record. It is disjoint from
/// everything else shipped here: measured against the catalogue, not one of the 1,652 Dota
/// implementations shares an address with a catalogued entry, and no name is shared either — a
/// `Script_TakeDamage` is a script-facing WRAPPER, a different function from the `TakeDamage` it wraps.
/// So these are additive, never a second account of something already described.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct VScriptBinding {
    /// What a Lua author calls (`TakeDamage`).
    pub name: String,
    /// The class that owns this member (`CDOTA_BaseNPC`).
    ///
    /// **Live-only, and absent from an offline build.** The class descriptor reaches the registration
    /// through a register loaded from memory rather than a `lea`, so constant propagation recovers it for
    /// none of the bindings; a running server resolves it through the record's owner pointer. This is the
    /// same shape as a schema field's TYPE, which is a null placeholder on disk and is why an offline run
    /// ships no typed netvars either.
    ///
    /// Consumers that group by class — `api.json`, the `.d.ts` the Dota ecosystem publishes — need this
    /// and cannot be rendered from a full build's output alone if it is missing.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub class: Option<String>,
    /// The C++ binding this resolves to (`Script_TakeDamage`) — and the key under which the
    /// implementation is folded into the catalogue, where it exists as a locator.
    pub cpp: String,
    pub library: String,
    /// Valve's own English description, where the registration supplies one.
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub description: String,
    /// The return type, decoded. Absent when the raw word is outside the corroborated set.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub ret: Option<String>,
    /// The raw return-type word, kept beside the decoding so a build that renumbers `ScriptDataType_t`
    /// can be re-read rather than silently mis-labelled — the rule `flags_raw` already follows.
    pub ret_raw: u16,
    /// The implementation address, or the vtable slot when the registration binds a virtual member.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub addr: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub vtable_slot: Option<u64>,
    /// The prose for the function this binding resolves to — see [`FunctionRecord::doc`].
    ///
    /// **Never on disk**, in either direction: it is joined at VIEW time by [`Rosetta::vscript`], which
    /// reads it off the record the row was folded onto. That is the whole point of carrying it on the
    /// row rather than looking it up by name in an emitter: an UNJOINED row bears a name that belongs
    /// to another module's function, so a name lookup would hand it prose about code it is not.
    #[serde(skip)]
    pub doc: Option<Doc>,
}

/// The binding registry's intrinsic identity (no wall-clock field, same rationale as [`MonoMeta`]).
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct BindingsMeta {
    pub game_key: String,
    pub source_build: String,
    pub pulse: usize,
    /// Of those, how many carry a recovered typed signature.
    #[serde(default)]
    pub pulse_typed: usize,
    /// Of those, how many carry a HOST-CALLABLE invocation shim (`call.needs == "args-only"`).
    #[serde(default)]
    pub pulse_callable: usize,
    pub entity_inputs: usize,
    #[serde(default)]
    pub entity_outputs: usize,
    #[serde(default)]
    pub entity_classes: usize,
    #[serde(default)]
    pub commands: usize,
    #[serde(default)]
    pub convars: usize,
    /// VScript bindings recovered.
    #[serde(default)]
    pub vscript: usize,
    /// Of those, how many were attributed to an owning class. Zero on an offline build by construction —
    /// the class is only readable from a running server.
    #[serde(default)]
    pub vscript_classed: usize,
    /// Of those, how many folded into the catalogue as a locator. Lower than `vscript` by the
    /// bindings whose implementation did not resolve and the handful whose C++ name is registered at
    /// more than one address — dropped, not guessed, exactly as the datadesc handlers are.
    #[serde(default)]
    pub vscript_located: usize,
}

impl Bindings {
    pub fn is_empty(&self) -> bool {
        self.pulse.is_empty()
            && self.entity_inputs.is_empty()
            && self.entity_outputs.is_empty()
            && self.entity_classes.is_empty()
            && self.commands.is_empty()
    }
}

// ===========================================================================================
// The prototype manifest — what a function TAKES, which a locator deliberately does not answer: a locator says where a function is, a prototype says how to call it, and
// the two have different sources and different lifetimes. Declarations are static and human-sourced;
// the VERDICT on each one is re-measured against every build.
// ===========================================================================================

/// The verdict on a declared prototype, judged against the footprint measured in THIS build.
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum AbiStatus {
    /// The declared arity matches the measured footprint — safe to call through.
    Verified,
    /// The callee reads a register the declaration does not mention. The declaration does not describe
    /// this build and MUST NOT be called through: such a call resolves, passes live validation, and then
    /// leaves a register the callee reads unset.
    Mismatch,
    /// The declaration passes registers the callee never reads, and contradicts it in no register class.
    ///
    /// NOT a mismatch, and separating the two is the point. The measured footprint is a documented LOWER
    /// bound — a callee that ignores an argument, a forwarding thunk that reads none of its own, an
    /// empty virtual override — so measuring FEWER arguments than declared is expected behaviour rather
    /// than evidence against the declaration. `CBaseEntity::SetAbsAngles` is the shape: declared
    /// `(this, float, float, float)`, measured `int=0 float=3`, every float agreeing exactly and only
    /// the unread `this` differing. Calling through one merely loads a register nobody reads, which is
    /// the opposite of the failure `Mismatch` names. 81 of CS2's 140 former mismatches are this.
    LowerBound,
    /// No measurement available to check it against.
    Unverified,
    /// Overloads the measurement could not separate.
    Ambiguous,
    /// A return type was declared and a parameter list was not, so there is no arity claim for the
    /// binary to confirm or refute. `CALL_VIRTUAL(RET, …)` sites are the source: they say what comes
    /// back and pass VALUES rather than types, so nothing about the signature can be read off them.
    ReturnOnly,
}

impl AbiStatus {
    pub fn as_str(self) -> &'static str {
        match self {
            AbiStatus::Verified => "verified",
            AbiStatus::Mismatch => "mismatch",
            AbiStatus::LowerBound => "lower-bound",
            AbiStatus::Unverified => "unverified",
            AbiStatus::Ambiguous => "ambiguous",
            AbiStatus::ReturnOnly => "return-only",
        }
    }
}

/// One function's declared prototype and the verdict this build gives it.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct AbiEntry {
    /// Which shipped tier the function is in. Cleared when folded onto a [`FunctionRecord`], which
    /// states it already.
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub tier: String,
    /// `exact` — the declaration names this function; `bare-name` — it names the same METHOD on some
    /// class, claimed only because exactly one declaration bears that name and a measurement could
    /// adjudicate.
    pub matched_by: String,
    pub status: AbiStatus,
    /// Declared parameter TYPES — the thing a machine-derived arity cannot supply.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub params: Option<Vec<String>>,
    /// Set when `params` is the FULL register-visible argument list, receiver included, because the
    /// declaration was a function-pointer type rather than a mangled symbol. Absent means `this` is not
    /// in the list and a caller has to supply it — the convention every Itanium-derived entry uses.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub params_complete: Option<bool>,
    #[serde(rename = "const", default, skip_serializing_if = "Option::is_none")]
    pub is_const: Option<bool>,
    /// A DECLARED return type where one exists, else the measured register class — a far weaker
    /// statement (see [`AbiShape`]), and one that is not evidence about the declared type.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub ret: Option<String>,
    /// Omitted on an `ambiguous` verdict, where no declaration was chosen.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub provenance: Vec<String>,
    /// The measured SysV footprint the verdict was reached against.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub derived: Option<AbiShape>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub note: Option<String>,
    /// Every signature the declarations offered, whenever there was more than one — whether the
    /// measurement went on to pick between them (see `note`) or could not, which is `ambiguous`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub overloads: Option<Vec<Vec<String>>>,
    /// The vtable slot this build's shipped locator resolves through, present exactly when the locator
    /// is a vtable offset that LIVE VALIDATION confirmed.
    ///
    /// It states something no measurement can: a slot is only reachable through an object, so the
    /// function HAS a receiver even where the footprint cannot see one. That case is not marginal —
    /// a getter that returns a constant never reads `this`, and backward liveness reads that as no
    /// argument at all, so `CBaseDoor::GetDataDescMap` measures `int=0` while genuinely taking one.
    ///
    /// Live validation is part of the condition rather than a separate check, because it is what rules
    /// out the one way an `offset` locator can fail to be a vtable slot: a carried MEMBER offset under a
    /// name the deriver could not classify, which the live oracle reports as `Unknown`/`Oob` instead of
    /// `Live`. An offline derive has no such evidence and therefore states no `vtable` at all —
    /// a smaller emittable set, never a guessed receiver.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub vtable: Option<i64>,
    /// The prose for this function — see [`FunctionRecord::doc`]. **Never on disk**: joined at VIEW
    /// time by [`Rosetta::abi_manifest`], off the record this prototype belongs to, so that the call
    /// sites a framework generates can carry it into an editor's tooltip.
    #[serde(skip)]
    pub doc: Option<Doc>,
}

impl AbiEntry {
    /// Drop what the function record states for itself. The tier and the measured footprint the verdict
    /// was reached against are both fields of the record this is folded onto — carrying them here too
    /// would give one fact two homes, and two homes is how they come to disagree.
    fn into_record_prototype(mut self) -> AbiEntry {
        self.tier.clear();
        self.derived = None;
        self
    }

    /// An entry with only the fields every verdict carries — the base for functional-update construction.
    pub fn blank() -> Self {
        Self {
            tier: String::new(),
            matched_by: String::new(),
            status: AbiStatus::Unverified,
            params: None,
            params_complete: None,
            is_const: None,
            ret: None,
            provenance: Vec::new(),
            derived: None,
            note: None,
            overloads: None,
            vtable: None,
            doc: None,
        }
    }
}

/// The prototype manifest's identity plus its verdict tally.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct AbiMeta {
    pub game_key: String,
    pub source_build: String,
    /// Verdict tally — `status:verified`, `core:resolved`, `high_confidence:none`, …
    pub counts: BTreeMap<String, usize>,
}

/// Every declared prototype this build could judge — [`merge`] folds each onto its function as
/// [`FunctionRecord::prototype`].
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct AbiManifest {
    pub meta: AbiMeta,
    pub functions: BTreeMap<String, AbiEntry>,
}

/// One typed schema field (the field NAME is the map key). `offset` is static; `ty`/`kind`/`size` are
/// runtime-resolved (empty/zero when derived offline without a live process).
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct Field {
    pub offset: i32,
    #[serde(rename = "type", default, skip_serializing_if = "String::is_empty")]
    pub ty: String,
    #[serde(default)]
    pub kind: FieldKind,
    pub size: usize,
    pub name_hash: u64,
}

/// How a schema field holds its value — a closed runtime domain (Source-2 `CSchemaType` category).
/// Serializes to lowercase tokens, the values a schema consumer expects.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FieldKind {
    /// Builtin / atomic / declared class / declared enum — held inline (the common case).
    #[default]
    Ref,
    /// A pointer to the value.
    Ptr,
    /// A fixed-size inline array.
    FixedArray,
}

/// A direct base class and the `this`-adjustment to reach it. The schema records these, and a consumer
/// needs them for the artifact's most basic question: `CCSPlayerPawn.m_iHealth` is not a field OF
/// `CCSPlayerPawn` — it is inherited, and only the base chain says where to look.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct BaseClass {
    pub name: String,
    pub offset: u32,
}

/// How a type travels when passed BY VALUE under the SysV-AMD64 convention — the fact a caller needs
/// that a field offset cannot supply.
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SysvClass {
    /// Travels in integer registers: pointers, handles, and small aggregates with any non-float member.
    Integer,
    /// Travels in SSE registers — an aggregate of 16 bytes or less whose members are all floating-point.
    /// `Vector` (3 floats) is the case that bites: by value it costs TWO SSE registers, by reference one
    /// integer register.
    Sse,
    /// Larger than 16 bytes, so it is passed in memory (effectively by reference) and returned through a
    /// hidden pointer. Size alone settles this one.
    Memory,
    /// Size known but composition unknown, or size unknown — a caller must not guess.
    Unknown,
}

/// Where a type's size came from — the honesty axis, since the two routes carry different confidence.
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum LayoutSource {
    /// The SchemaSystem states the class's instance size outright.
    Schema,
    /// Inferred from the distance to the next field across many classes — exact for every primitive whose
    /// size is independently known, but an inference nonetheless.
    FieldGap,
    /// Declared in the deriver, for the closed set of engine primitives the schema does not register.
    Declared,
}

/// What a consumer needs to pass or hold a value of some type: how big it is, and how it travels.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct TypeLayout {
    pub size: usize,
    pub sysv: SysvClass,
    pub source: LayoutSource,
    /// How many field observations backed a `field-gap` size, and how many agreed — omitted for the other
    /// sources, where the size is stated rather than inferred.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub observations: Option<usize>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub agreement: Option<usize>,
}

/// One enumerator: the name and the value it stands for.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct EnumValue {
    pub name: String,
    pub value: i64,
}

/// A registered enum — the semantic vocabulary behind an integer field. `m_MoveType = 2` is only
/// meaningful as `MOVETYPE_WALK`, and a register footprint can never recover that.
///
/// Unlike a field's TYPE, this is static data: the SchemaSystem records enum bindings in the binary, so
/// these are read from the image rather than from the running process.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct EnumDef {
    /// Underlying integer width in bytes — 1 for `MoveType_t`, 4 for `gear_slot_t`. Recorded by the
    /// binding, so the distinction is read rather than assumed.
    pub size: u8,
    /// Enumerators in DECLARATION order. A list, not a map: names are unique but values are not, since
    /// aliases (`MOVETYPE_LAST` / `MOVETYPE_INVALID`) legitimately share one.
    pub values: Vec<EnumValue>,
}

/// The typed schema, ships as the artifact's `schema` section. Merges field offsets with runtime types:
/// class -> field -> [`Field`], plus the enum vocabulary those fields refer to.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct Schema {
    pub meta: SchemaMeta,
    pub classes: BTreeMap<String, BTreeMap<String, Field>>,
    /// Direct base classes per class. `classes` holds each class's OWN fields only, so resolving an
    /// inherited member means walking this.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub bases: BTreeMap<String, Vec<BaseClass>>,
    /// Registered enums by name. Empty on an older artifact that predates the enum walk.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub enums: BTreeMap<String, EnumDef>,
    /// Size + SysV class for every type the fields above refer to — what a caller needs in order to pass
    /// one, which neither an offset nor a register footprint can supply.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub types: BTreeMap<String, TypeLayout>,
}

/// The typed schema's intrinsic identity (no wall-clock field, same rationale as [`MonoMeta`]).
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct SchemaMeta {
    pub game_key: String,
    pub source_build: String,
    pub typed: usize,
    pub untyped: usize,
    /// Registered enums recovered (0 on an artifact that predates the enum walk).
    #[serde(default)]
    pub enums: usize,
    /// Types with a recovered size + SysV class.
    #[serde(default)]
    pub types: usize,
}

// ===========================================================================================
// The merged release artifact — the shipped `rosetta-<game>.json`. ONE record per function, joining
// what the four derivation stages each know about it: where it is, what its machine code was measured
// to take, what a declaration says it takes, what the binary declares may be done with it, and what it
// means. Those are separate STAGES with separate confidence, not separate artifacts: keyed by name,
// they describe one thing, and a consumer answering "may I call this, and how" needed all four open at
// once to find out.
//
// The split that survives is between what is FUNCTION-KEYED and what is not. A Pulse binding names no
// C++ function, an entity output is a member rather than a method, and a classname maps names to names
// — none of those has a function record to live on, so they stay whole under `surfaces`.
// ===========================================================================================

/// Which shipped tier a function record came from — the section names of the tiered catalogue, kept as
/// a per-record field now that the tiers are no longer separate maps.
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Section {
    Core,
    HighConfidence,
    Experimental,
}

impl Section {
    /// The section's id, the same string the serialization emits.
    pub fn as_str(self) -> &'static str {
        match self {
            Section::Core => "core",
            Section::HighConfidence => "high_confidence",
            Section::Experimental => "experimental",
        }
    }
}

/// What a function is FOR, in plain language, for a reader who has the locator and still does not know
/// what the function does.
///
/// Keyed on the NAME rather than any locator, and deliberately SIGNATURE-FREE: arity, types and verdicts
/// live in [`FunctionRecord::prototype`] and are joined at render time, so a prototype changing under a
/// new build cannot make a description wrong.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct Description {
    pub text: String,
    /// How the text was arrived at — `derived` where the surrounding facts fix the meaning mechanically,
    /// `generated` otherwise. Valve's own prose is NOT carried here: it rides the binding it came with,
    /// and duplicating it would create a second place for one fact to go stale.
    pub source: String,
}

/// One function's prose as an emitter prints it: the text, and where it came from.
///
/// Distinct from [`Description`] because it is a RESOLVED view over every place prose can live. A
/// `Description` is what this artifact authored; a `Doc` may equally be Valve's own text, lifted off the
/// binding that carried it. `source` says which, and every emitter prints it beside the text — a plugin
/// author acting on a sentence must never have to guess whether Valve wrote it or this project did.
#[derive(Clone, Debug)]
pub struct Doc {
    /// Collapsed to ONE line (see [`one_line`]): every target embeds this in a comment, and two of the
    /// three comment syntaxes involved end at a newline.
    pub text: String,
    /// [`Doc::VALVE`], or the authored [`Description::source`] id.
    pub source: String,
}

impl Doc {
    /// The `source` of text the binary itself carries. Not a value [`Description::source`] ever holds —
    /// Valve's prose rides its binding — so it cannot collide with an authored id.
    pub const VALVE: &'static str = "valve";
}

/// Collapse every whitespace run to a single space.
///
/// Load-bearing rather than cosmetic: 78 CS2 and 82 Dota descriptions contain a newline, and both the
/// C# `///` and the C++ `//` comment forms END at one — an unflattened description would put the rest
/// of Valve's sentence into the generated source as code.
pub(crate) fn one_line(s: &str) -> String {
    s.split_whitespace().collect::<Vec<_>>().join(" ")
}

/// What the binary itself declares may be done with this function, where it declares anything at all.
///
/// Internally tagged, because the three cases carry genuinely different fields and a consumer switches on
/// which one it got: an entity-IO handler answers a name a map fires, a console handler answers a command
/// an operator types, and a VScript binding is the same function exposed to the script VM under another
/// name with Valve's own documentation attached.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum FunctionBinding {
    EntityInput {
        /// The input name entity IO addresses — `Kill`, `Enable`, `SetSpeed`.
        input: String,
        /// The owning class where the datadesc array's field descriptors identified one.
        class: Option<String>,
    },
    Command {
        command: String,
        /// Valve's own help text, `null` where the registration passes none.
        description: Option<String>,
        /// FCVAR bits with a measured meaning, `null` where the word sets none of them.
        flags: Option<Vec<String>>,
        /// The raw flags word, kept beside the decoding so a build that repurposes a bit can be re-read.
        flags_raw: String,
        /// How the registration passed its callback — `direct`, `interface`, `member`.
        callback_form: String,
    },
    Vscript {
        /// What a Lua author types, as opposed to the C++ name this record is keyed by.
        script_name: String,
        class: Option<String>,
        description: Option<String>,
        ret: Option<String>,
        ret_raw: u16,
    },
}

/// One function, everything the release knows about it.
///
/// The locator is flattened in exactly as the tiered catalogue flattened it, so a consumer that only
/// wants an address reads the same shape as before. Everything after it is a different KIND of fact, and
/// the field names say which: `measured` is read out of this build's machine code, `prototype` is a human
/// declaration judged against that measurement, `binding` is the binary's own declaration about the
/// function, and `description` is authored prose.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct FunctionRecord {
    pub tier: Section,
    #[serde(flatten)]
    pub locator: Entry,
    /// The SysV argument footprint read out of this build — see [`AbiShape`]. Absent when the address
    /// was not resolvable offline.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub measured: Option<AbiShape>,
    /// Live-validation verdict, and it is THREE-VALUED: `true` passed, `false` dropped confident-bad,
    /// `null` not checked here. Always emitted, never skipped — `null` is a statement (an offline build,
    /// a library the vanilla server does not map, a non-vtable class) and an absent key would leave a
    /// reader unable to tell it from a field this artifact forgot.
    #[serde(default)]
    pub validated: Option<bool>,
    /// The other shipped names for this same function — see [`MonoEntry::aliases`].
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub aliases: Vec<String>,
    pub provenance: Provenance,
    /// The declared prototype and this build's verdict on it, where one was declared.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub prototype: Option<AbiEntry>,
    /// What the binary declares may be done with this function — a LIST, because one function can be
    /// several of them: `AddOutput` is registered on three classes at once, and a console name can be
    /// registered by more than one library. Keeping only the last would assert one class as the class.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub bindings: Vec<FunctionBinding>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<Description>,
}

impl FunctionRecord {
    /// The one piece of prose to print above this function, resolved across every place prose can be.
    ///
    /// **Valve's own text always wins.** It is a statement by the people who wrote the function, read
    /// out of the binary's own registries; [`Description`] is this project's reading of the build, and a
    /// reading has no business overriding the source. So a generated description FILLS A GAP and never
    /// displaces one — which also means the two can never disagree in a shipped output.
    ///
    /// Between Valve's two registries the VScript one wins, because the two describe different things: a
    /// script binding's description documents the FUNCTION, while a console registration's help text
    /// documents the COMMAND that reaches it ("`sv_cheats <0/1>` — enable cheats") and is written for an
    /// operator typing it, not a caller.
    ///
    /// `None` where nothing describes the function at all, which is the majority: 5,287 of 8,361 CS2
    /// records. An emitter renders those exactly as it did before descriptions existed.
    pub fn doc(&self) -> Option<Doc> {
        // Empty is not documented: a registration that passes an empty help string has said nothing,
        // and skipping it here is what lets the search fall through to the next binding — and then to
        // the generated text — instead of stopping on a blank.
        let valve = |want_vscript: bool| {
            self.bindings.iter().find_map(|b| {
                match b {
                    FunctionBinding::Vscript { description, .. } if want_vscript => {
                        description.as_deref()
                    }
                    FunctionBinding::Command { description, .. } if !want_vscript => {
                        description.as_deref()
                    }
                    _ => None,
                }
                .filter(|d| !d.is_empty())
            })
        };
        if let Some(text) = valve(true).or_else(|| valve(false)) {
            return Some(Doc {
                text: one_line(text),
                source: Doc::VALVE.to_string(),
            });
        }
        self.description.as_ref().map(|d| Doc {
            text: one_line(&d.text),
            source: d.source.clone(),
        })
    }
}

/// The typed schema, as a section of the merged artifact.
///
/// **Live-only.** Field types are runtime-resolved, so an offline build has no schema to state — which is
/// why [`Rosetta::schema`] is an explicit `null` rather than an absent key: absence would be
/// indistinguishable from a build that resolved zero classes.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct SchemaSection {
    pub classes: BTreeMap<String, BTreeMap<String, Field>>,
    pub bases: Option<BTreeMap<String, Vec<BaseClass>>>,
    pub enums: Option<BTreeMap<String, EnumDef>>,
    pub types: Option<BTreeMap<String, TypeLayout>>,
    pub meta: Option<SchemaMeta>,
}

impl From<Schema> for SchemaSection {
    fn from(s: Schema) -> Self {
        fn opt<K, V>(m: BTreeMap<K, V>) -> Option<BTreeMap<K, V>> {
            (!m.is_empty()).then_some(m)
        }
        SchemaSection {
            classes: s.classes,
            bases: opt(s.bases),
            enums: opt(s.enums),
            types: opt(s.types),
            meta: Some(s.meta),
        }
    }
}

/// The declared surfaces that are NOT function-keyed, so cannot fold into a function record.
///
/// Each is here for its own reason rather than as a leftovers bin: a Pulse binding does not name a C++
/// function at all (its `shim` is an entry point into Valve's marshalling, not the bound method), an
/// entity output is a member offset rather than a method, a classname binds one name to another, a
/// ConVar is configuration rather than code, and an unlocated VScript binding is documentation for a
/// function this build could not place.
#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct Surfaces {
    #[serde(default)]
    pub pulse: BTreeMap<String, Binding>,
    #[serde(default)]
    pub entity_outputs: Vec<EntityOutput>,
    #[serde(default)]
    pub entity_classes: BTreeMap<String, String>,
    #[serde(default)]
    pub convars: Vec<ConVar>,
    /// Declared rows that belong to no function record here. See [`Unjoined`].
    #[serde(default)]
    pub unjoined: Unjoined,
}

/// What the binary declares about functions this build does not describe — the other side of the
/// function-keyed joins, kept rather than dropped so the merge loses nothing the deriver read.
///
/// Two things put a row here, and they are different in kind. Either **nothing located it**: the
/// implementation did not resolve, or the handler's name was ambiguous and was dropped rather than
/// guessed. Or **the name belongs to another module's function**: a console name registered by two
/// libraries, or a script name registered in three, is several functions, while the catalogue holds one
/// entry under that name — so the rows from the other modules describe code this artifact does not
/// locate, and asserting them onto the one record it does would be a claim about the wrong function.
///
/// Each row keeps its own `library` and `addr`, which is what tells the two cases apart.
#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct Unjoined {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub entity_inputs: Vec<EntityInput>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub commands: Vec<ConsoleCommand>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub vscript: Vec<VScriptBinding>,
}

/// How many records each join reached — reported so a collapse in any one of them is visible in the
/// artifact rather than only in a derive log.
#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct Joined {
    pub prototypes: usize,
    pub bindings: JoinedBindings,
    pub descriptions: usize,
}

#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct JoinedBindings {
    #[serde(rename = "entity-input")]
    pub entity_input: usize,
    pub command: usize,
    pub vscript: usize,
}

/// The merged artifact's identity — the catalogue's own release identity plus what the merge joined.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct RosettaMeta {
    pub game_key: String,
    pub game: String,
    pub source_build: String,
    pub version: String,
    pub counts: Counts,
    pub alias_groups: usize,
    pub aliased_names: usize,
    pub merged: bool,
    pub joined: Joined,
}

/// The shipped `rosetta-<game>.json` — one file, one record per function.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct Rosetta {
    pub meta: RosettaMeta,
    pub functions: BTreeMap<String, FunctionRecord>,
    /// Catalogued but not produced this build — no locator, so not a function record.
    pub unresolved: BTreeMap<String, Unresolved>,
    /// `None` on an offline build, serialized as an explicit `null`. See [`SchemaSection`].
    pub schema: Option<SchemaSection>,
    pub surfaces: Surfaces,
}

impl Rosetta {
    /// The locators alone, at a confidence tier — what a framework's LOADER resolves through.
    ///
    /// Drops entries live validation confidently rejected, the same rule the tiered catalogue applied:
    /// a `validated: false` entry is one the running server disagreed with, and shipping it into a
    /// loader would hand a consumer an address the deriver already knows is wrong.
    pub fn gamedata(&self, select: TierSelect) -> Gamedata {
        let keep = |t: Section| match select {
            TierSelect::Core => t == Section::Core,
            TierSelect::HighConfidence => t != Section::Experimental,
            TierSelect::Experimental => true,
        };
        Gamedata {
            entries: self
                .functions
                .iter()
                .filter(|(_, r)| keep(r.tier) && r.validated != Some(false))
                .map(|(n, r)| (n.clone(), r.locator.clone()))
                .collect(),
            game_key: self.meta.game_key.clone(),
        }
    }

    /// The tiered catalogue view — the shape the CS# combined renderer works from, which needs the tiers
    /// as separate sections in order to banner them differently.
    pub fn to_monolith(&self) -> Monolith {
        let mut m = Monolith {
            meta: MonoMeta {
                game_key: self.meta.game_key.clone(),
                game: self.meta.game.clone(),
                source_build: self.meta.source_build.clone(),
                version: self.meta.version.clone(),
                counts: self.meta.counts.clone(),
                alias_groups: self.meta.alias_groups,
                aliased_names: self.meta.aliased_names,
            },
            core: BTreeMap::new(),
            high_confidence: BTreeMap::new(),
            experimental: BTreeMap::new(),
            unresolved: self.unresolved.clone(),
        };
        for (name, r) in &self.functions {
            let section = match r.tier {
                Section::Core => &mut m.core,
                Section::HighConfidence => &mut m.high_confidence,
                Section::Experimental => &mut m.experimental,
            };
            section.insert(
                name.clone(),
                MonoEntry {
                    locator: r.locator.clone(),
                    abi: r.measured.clone(),
                    provenance: r.provenance.clone(),
                    validated: r.validated,
                    aliases: r.aliases.clone(),
                },
            );
        }
        m
    }

    /// The declared prototypes, as the manifest the call-site emitters consume.
    ///
    /// `tier` and `derived` are restored from the record, which is where the merge moved them — the
    /// receiver test in `render::callable_shapes` reads the measured footprint, so a view that dropped
    /// it would quietly emit fewer call sites than the data supports. `doc` is joined the same way, and
    /// is what puts a sentence in a plugin author's editor tooltip rather than only in the artifact.
    pub fn abi_manifest(&self) -> AbiManifest {
        AbiManifest {
            meta: AbiMeta {
                game_key: self.meta.game_key.clone(),
                source_build: self.meta.source_build.clone(),
                counts: BTreeMap::new(),
            },
            functions: self
                .functions
                .iter()
                .filter_map(|(n, r)| {
                    let p = r.prototype.as_ref()?;
                    Some((
                        n.clone(),
                        AbiEntry {
                            tier: r.tier.as_str().to_string(),
                            derived: r.measured.clone(),
                            doc: r.doc(),
                            ..p.clone()
                        },
                    ))
                })
                .collect(),
        }
    }

    /// The VScript registry, whole: the members folded onto functions plus the ones that joined nowhere.
    ///
    /// Both belong in a rendered API — a member Valve documents is part of the surface a script author
    /// sees whether or not this build located its implementation.
    ///
    /// A folded row also picks up its function's [`doc`](FunctionRecord::doc); an unjoined one does not,
    /// and must not. Its C++ name may be a name another module's function also bears, so prose looked up
    /// by that name would describe code this row is not — the same trap the merge itself refuses.
    pub fn vscript(&self) -> Vec<VScriptBinding> {
        let mut out: Vec<VScriptBinding> = self
            .functions
            .iter()
            .flat_map(|(cpp, r)| {
                r.bindings.iter().filter_map(move |b| match b {
                    FunctionBinding::Vscript {
                        script_name,
                        class,
                        description,
                        ret,
                        ret_raw,
                    } => Some(VScriptBinding {
                        name: script_name.clone(),
                        class: class.clone(),
                        cpp: cpp.clone(),
                        library: r
                            .locator
                            .signature
                            .as_ref()
                            .map(|s| s.library.clone())
                            .unwrap_or_default(),
                        description: description.clone().unwrap_or_default(),
                        ret: ret.clone(),
                        ret_raw: *ret_raw,
                        addr: r.provenance.addr.clone(),
                        vtable_slot: None,
                        doc: r.doc(),
                    }),
                    _ => None,
                })
            })
            .collect();
        out.extend(self.surfaces.unjoined.vscript.iter().cloned());
        out
    }

    /// The typed schema as its own model — `None` on an offline build, where there is no schema to state.
    pub fn typed_schema(&self) -> Option<Schema> {
        let s = self.schema.as_ref()?;
        Some(Schema {
            meta: s.meta.clone().unwrap_or(SchemaMeta {
                game_key: self.meta.game_key.clone(),
                source_build: self.meta.source_build.clone(),
                typed: 0,
                untyped: 0,
                enums: 0,
                types: 0,
            }),
            classes: s.classes.clone(),
            bases: s.bases.clone().unwrap_or_default(),
            enums: s.enums.clone().unwrap_or_default(),
            types: s.types.clone().unwrap_or_default(),
        })
    }
}

/// Fold the derivation's four outputs into one artifact.
///
/// A pure function of the models — it reads nothing from disk and infers nothing new, so every field in
/// the result is one a stage already produced. That is what makes the shape checkable against an
/// independent merge of the same inputs.
///
/// Joins are by NAME, and a record that finds no home is not silently dropped: an unjoinable VScript
/// row lands in [`Surfaces::unjoined`], so the merge is lossless: every declared row is either on the
/// function it describes or stated as a surface, and [`Joined`] counts which.
pub fn merge(
    mono: Monolith,
    abi: Option<AbiManifest>,
    bindings: Option<Bindings>,
    schema: Option<Schema>,
    descriptions: BTreeMap<String, Description>,
) -> Rosetta {
    let Monolith {
        meta,
        core,
        high_confidence,
        experimental,
        unresolved,
    } = mono;

    let mut functions: BTreeMap<String, FunctionRecord> = BTreeMap::new();
    for (tier, section) in [
        (Section::Core, core),
        (Section::HighConfidence, high_confidence),
        (Section::Experimental, experimental),
    ] {
        for (name, e) in section {
            // Most-confident tier first, and FIRST WRITER WINS. The tiers are disjoint upstream, so this
            // never fires — but if one ever did carry a name twice, keeping the first means a `core`
            // function cannot be relabelled as an `experimental` guess by iteration order alone.
            functions.entry(name).or_insert(FunctionRecord {
                tier,
                locator: e.locator,
                measured: e.abi,
                validated: e.validated,
                aliases: e.aliases,
                provenance: e.provenance,
                prototype: None,
                bindings: Vec::new(),
                description: None,
            });
        }
    }

    let mut joined = Joined::default();

    // The DECLARED prototype beside the MEASURED footprint: two different kinds of fact about one
    // function, and the verdict on the first is reached against the second.
    if let Some(abi) = abi {
        for (name, row) in abi.functions {
            if let Some(rec) = functions.get_mut(&name) {
                rec.prototype = Some(row.into_record_prototype());
                joined.prototypes += 1;
            }
        }
    }

    let mut surfaces = Surfaces::default();
    if let Some(b) = bindings {
        // A name is only unique WITHIN a module: `AddOutput` is registered in three libraries and
        // `cl_particles_dumplist` in two, each a different function, while the catalogue holds one entry
        // under that name. Attaching every registration to that one record would assert bindings that
        // belong to code it does not locate — the bare-name trap, from the other direction. So a row is
        // attached only when the record cannot contradict it: same library, or a record whose locator is
        // a vtable slot and therefore names no library at all.
        let joins = |rec: &FunctionRecord, lib: &str| {
            rec.locator
                .signature
                .as_ref()
                .is_none_or(|sig| sig.library == lib)
        };
        for r in b.entity_inputs {
            let key = match &r.class {
                Some(c) => format!("{c}::{}", r.handler),
                None => r.handler.clone(),
            };
            match functions.get_mut(&key).filter(|rec| joins(rec, &r.library)) {
                Some(rec) => {
                    rec.bindings.push(FunctionBinding::EntityInput {
                        input: r.input.clone(),
                        class: r.class.clone(),
                    });
                    joined.bindings.entity_input += 1;
                }
                None => surfaces.unjoined.entity_inputs.push(r),
            }
        }
        for c in b.commands {
            let key = format!("ConCommand::{}", c.name);
            match functions.get_mut(&key).filter(|rec| joins(rec, &c.library)) {
                Some(rec) => {
                    rec.bindings.push(FunctionBinding::Command {
                        command: c.name,
                        description: none_if_empty(c.description),
                        flags: (!c.flags.is_empty()).then_some(c.flags),
                        flags_raw: c.flags_raw,
                        callback_form: c.form,
                    });
                    joined.bindings.command += 1;
                }
                None => surfaces.unjoined.commands.push(c),
            }
        }
        for v in b.vscript {
            match functions
                .get_mut(&v.cpp)
                .filter(|rec| joins(rec, &v.library))
            {
                Some(rec) => {
                    rec.bindings.push(FunctionBinding::Vscript {
                        script_name: v.name,
                        class: v.class,
                        description: none_if_empty(v.description),
                        ret: v.ret,
                        ret_raw: v.ret_raw,
                    });
                    joined.bindings.vscript += 1;
                }
                None => surfaces.unjoined.vscript.push(v),
            }
        }
        surfaces.pulse = b.pulse;
        surfaces.entity_outputs = b.entity_outputs;
        surfaces.entity_classes = b.entity_classes;
        surfaces.convars = b.convars;
    }

    for (name, d) in descriptions {
        if let Some(rec) = functions.get_mut(&name) {
            rec.description = Some(d);
            joined.descriptions += 1;
        }
    }

    Rosetta {
        meta: RosettaMeta {
            game_key: meta.game_key,
            game: meta.game,
            source_build: meta.source_build,
            version: meta.version,
            counts: meta.counts,
            alias_groups: meta.alias_groups,
            aliased_names: meta.aliased_names,
            merged: true,
            joined,
        },
        functions,
        unresolved,
        schema: schema.map(SchemaSection::from),
        surfaces,
    }
}

/// An empty string is the artifact's way of saying a source supplied nothing; the merged record says so
/// with `null` instead, so a reader never has to know which spelling of absence a given surface used.
fn none_if_empty(s: String) -> Option<String> {
    (!s.is_empty()).then_some(s)
}

#[cfg(test)]
mod merge_tests {
    use super::*;
    use serde_json::json;

    /// Fixtures are built by DESERIALIZING the artifact shapes, so a test states what a reader sees on
    /// disk rather than which constructor the deriver happened to use.
    fn from<T: for<'de> serde::Deserialize<'de>>(v: serde_json::Value) -> T {
        serde_json::from_value(v).expect("fixture parses as the artifact shape")
    }

    /// The keys of a serialized object, IN THE ORDER WRITTEN. `serde_json::Value` cannot answer this —
    /// its map is key-sorted — and the order is part of what the artifact promises, so it is read back
    /// off the text the way a consumer diffing two releases would see it.
    fn field_order(json: &str) -> Vec<String> {
        let (mut keys, mut depth, mut in_str, mut esc, mut cur) =
            (Vec::new(), 0i32, false, false, String::new());
        for c in json.chars() {
            if in_str {
                match c {
                    _ if esc => esc = false,
                    '\\' => esc = true,
                    '"' => in_str = false,
                    _ if depth == 1 => cur.push(c),
                    _ => {}
                }
                continue;
            }
            match c {
                '"' => {
                    in_str = true;
                    cur.clear();
                }
                ':' if depth == 1 && !cur.is_empty() => keys.push(std::mem::take(&mut cur)),
                '{' | '[' => depth += 1,
                '}' | ']' => depth -= 1,
                _ => {}
            }
        }
        keys
    }

    fn mono(entries: serde_json::Value) -> Monolith {
        from(json!({
            "meta": { "game_key": "csgo", "game": "CS2", "source_build": "b", "version": "cs2-1-0",
                      "counts": { "core": 1, "high_confidence": 1, "experimental": 0, "unresolved": 0 } },
            "core": entries,
            "high_confidence": {}, "experimental": {}, "unresolved": {},
        }))
    }

    #[test]
    fn merge_folds_every_stage_onto_one_record() {
        let m = mono(json!({
            "ConCommand::bot_add": {
                "signature": { "library": "server", "linux": "55 48" },
                "abi": { "int": 2, "float": 0, "ret": "ret=int" },
                "provenance": { "tier": "valve-table" },
                "validated": true,
            }
        }));
        let abi: AbiManifest = from(json!({
            "meta": { "game_key": "csgo", "source_build": "b", "counts": {} },
            "functions": { "ConCommand::bot_add": {
                "tier": "core", "matched_by": "engine-contract", "status": "verified",
                "params": ["CCommandContext*"], "ret": "void" } },
        }));
        let bindings: Bindings = from(json!({
            "meta": { "game_key": "csgo", "source_build": "b", "pulse": 0, "entity_inputs": 0 },
            "pulse": {},
            "entity_inputs": [],
            "commands": [{ "name": "bot_add", "library": "server", "description": "adds a bot",
                           "flags": ["release"], "flags_raw": "0x4", "form": "direct",
                           "addr": "0x1" }],
        }));
        let desc: BTreeMap<String, Description> = from(json!({
            "ConCommand::bot_add": { "text": "Adds a bot.", "source": "generated" }
        }));

        let r = merge(m, Some(abi), Some(bindings), None, desc);
        assert_eq!(r.meta.joined.prototypes, 1);
        assert_eq!(r.meta.joined.bindings.command, 1);
        assert_eq!(r.meta.joined.descriptions, 1);

        // The locator stays flattened, and every other stage is a named field beside it — which is the
        // whole shape claim: one record, four kinds of fact, each labelled by where it came from.
        let rec_json = serde_json::to_string(&r.functions["ConCommand::bot_add"]).unwrap();
        assert_eq!(
            field_order(&rec_json),
            [
                "tier",
                "signature",
                "measured",
                "validated",
                "provenance",
                "prototype",
                "bindings",
                "description"
            ]
        );
        let v = serde_json::to_value(&r).unwrap();
        let rec = &v["functions"]["ConCommand::bot_add"];
        assert_eq!(rec["tier"], "core");
        assert_eq!(rec["measured"]["int"], 2);
        assert_eq!(rec["prototype"]["status"], "verified");
        assert_eq!(rec["bindings"][0]["kind"], "command");
        assert_eq!(rec["bindings"][0]["command"], "bot_add");
        // the prototype sheds what the record already says
        assert!(rec["prototype"].get("tier").is_none());
        assert!(rec["prototype"].get("derived").is_none());
        assert_eq!(rec["description"]["source"], "generated");
        assert!(v["schema"].is_null()); // offline: an explicit null, never an absent key
    }

    #[test]
    fn a_source_that_supplied_nothing_reads_as_null_not_as_empty() {
        let m = mono(json!({ "ConCommand::x": { "offset": 3, "provenance": { "tier": "core" } } }));
        let bindings: Bindings = from(json!({
            "meta": { "game_key": "csgo", "source_build": "b", "pulse": 0, "entity_inputs": 0 },
            "pulse": {}, "entity_inputs": [],
            "commands": [{ "name": "x", "library": "server", "flags_raw": "0x0", "form": "direct",
                           "addr": "0x1" }],
        }));
        let v =
            serde_json::to_value(merge(m, None, Some(bindings), None, BTreeMap::new())).unwrap();
        let b = &v["functions"]["ConCommand::x"]["bindings"][0];
        assert!(b["description"].is_null());
        assert!(b["flags"].is_null());
        assert_eq!(b["flags_raw"], "0x0");
    }

    #[test]
    fn a_binding_from_another_library_is_not_asserted_onto_this_function() {
        // One name, three registrations, three different functions — the catalogue holds the `server`
        // one. The other two describe code this build does not locate, so they must not become claims
        // about the function it does.
        let m = mono(json!({ "AddOutput": {
            "signature": { "library": "server", "linux": "55" },
            "provenance": { "tier": "core" } } }));
        let row = |lib: &str| {
            json!({ "name": "AddOutput", "cpp": "AddOutput", "class": "CNativeOutputs",
                    "library": lib, "ret_raw": 0 })
        };
        let bindings: Bindings = from(json!({
            "meta": { "game_key": "csgo", "source_build": "b", "pulse": 0, "entity_inputs": 0 },
            "pulse": {}, "entity_inputs": [],
            "vscript": [row("server"), row("engine2"), row("worldrenderer")],
        }));
        let r = merge(m, None, Some(bindings), None, BTreeMap::new());
        assert_eq!(r.meta.joined.bindings.vscript, 1);
        assert_eq!(r.functions["AddOutput"].bindings.len(), 1);
        assert_eq!(r.surfaces.unjoined.vscript.len(), 2);
    }

    #[test]
    fn the_views_hand_each_emitter_what_it_reads() {
        let mut m = mono(json!({
            "A::keep": { "signature": { "library": "server", "linux": "55" },
                         "abi": { "int": 2, "float": 0, "ret": "ret=int" },
                         "provenance": { "tier": "core" }, "validated": true },
            "A::rejected": { "offset": 9, "provenance": { "tier": "core" }, "validated": false },
        }));
        // a guess, which `core` must not include
        m.experimental.insert(
            "A::guess".into(),
            from(json!({ "offset": 4, "provenance": { "tier": "low" }, "validated": null })),
        );
        let abi: AbiManifest = from(json!({
            "meta": { "game_key": "csgo", "source_build": "b", "counts": {} },
            "functions": { "A::keep": { "tier": "core", "matched_by": "exact", "status": "verified",
                                        "params": ["int"] } },
        }));
        let r = merge(m, Some(abi), None, None, BTreeMap::new());

        // the locator half: tier-filtered, and an entry live validation rejected is not a locator
        let core = r.gamedata(TierSelect::Core);
        assert_eq!(core.entries.keys().collect::<Vec<_>>(), ["A::keep"]);
        assert_eq!(
            r.gamedata(TierSelect::Experimental).entries.len(),
            2 // keep + guess; the rejected one stays out at every tier
        );

        // the prototype half: the two fields the merge moved onto the record are restored, because the
        // receiver test in `render::callable_shapes` reads the measurement
        let man = r.abi_manifest();
        let e = &man.functions["A::keep"];
        assert_eq!(e.tier, "core");
        assert_eq!(e.derived.as_ref().map(|d| d.int), Some(2));
        assert_eq!(man.meta.game_key, "csgo");
    }

    #[test]
    fn the_script_api_view_carries_the_members_that_joined_and_the_ones_that_did_not() {
        let m = mono(
            json!({ "Script_Kill": { "signature": { "library": "server", "linux": "55" },
                                              "provenance": { "tier": "core", "addr": "0x1" } } }),
        );
        let bindings: Bindings = from(json!({
            "meta": { "game_key": "csgo", "source_build": "b", "pulse": 0, "entity_inputs": 0 },
            "pulse": {}, "entity_inputs": [],
            "vscript": [
                { "name": "Kill", "cpp": "Script_Kill", "class": "CBaseEntity", "library": "server",
                  "description": "Kills it", "ret": "void", "ret_raw": 0 },
                { "name": "Gone", "cpp": "Script_Gone", "class": "CBaseEntity", "library": "server",
                  "ret_raw": 0 },
            ],
        }));
        let r = merge(m, None, Some(bindings), None, BTreeMap::new());
        let vs = r.vscript();
        assert_eq!(vs.len(), 2); // one on its function, one unjoined — both are surface a script sees
        let joined = vs.iter().find(|v| v.cpp == "Script_Kill").unwrap();
        assert_eq!(joined.name, "Kill");
        assert_eq!(joined.description, "Kills it");
        assert_eq!(joined.library, "server"); // taken from the record's own locator
        assert!(vs.iter().any(|v| v.cpp == "Script_Gone"));
        assert!(r.typed_schema().is_none()); // offline: nothing to render a schema from
    }

    #[test]
    fn valves_own_text_always_wins_and_a_generated_one_only_fills_a_gap() {
        let vs = |cpp: &str, desc: &str| {
            json!({ "name": "N", "cpp": cpp, "class": "C", "library": "server",
                    "description": desc, "ret_raw": 0 })
        };
        let m = mono(json!({
            "ConCommand::documented": { "offset": 1, "provenance": { "tier": "core" } },
            "ConCommand::help_only":  { "offset": 2, "provenance": { "tier": "core" } },
            "ConCommand::ours":       { "offset": 3, "provenance": { "tier": "core" } },
            "ConCommand::silent":     { "offset": 4, "provenance": { "tier": "core" } },
        }));
        let bindings: Bindings = from(json!({
            "meta": { "game_key": "csgo", "source_build": "b", "pulse": 0, "entity_inputs": 0 },
            "pulse": {}, "entity_inputs": [],
            // Both registries describe the first one. A console help string documents the COMMAND an
            // operator types; the script registry documents the FUNCTION, so it is the one that wins.
            "commands": [
                { "name": "documented", "library": "server", "flags_raw": "0x0", "form": "direct",
                  "addr": "0x1", "description": "type `documented 1` to do the thing" },
                { "name": "help_only", "library": "server", "flags_raw": "0x0", "form": "direct",
                  "addr": "0x2", "description": "Valve's help text" },
            ],
            "vscript": [
                // The first binding carries nothing: an empty registration has said nothing, so the
                // search must fall THROUGH it rather than stop on a blank.
                vs("ConCommand::documented", ""),
                vs("ConCommand::documented", "Valve's script prose"),
            ],
        }));
        let desc: BTreeMap<String, Description> = from(json!({
            "ConCommand::documented": { "text": "ours, and outranked", "source": "generated" },
            "ConCommand::help_only":  { "text": "ours, and outranked", "source": "generated" },
            "ConCommand::ours":       { "text": "a\nreading\tof   this build", "source": "generated" },
        }));
        let r = merge(m, None, Some(bindings), None, desc);
        let doc = |n: &str| r.functions[n].doc();

        let d = doc("ConCommand::documented").unwrap();
        assert_eq!(d.text, "Valve's script prose");
        assert_eq!(d.source, Doc::VALVE);
        let d = doc("ConCommand::help_only").unwrap();
        assert_eq!(d.text, "Valve's help text");
        assert_eq!(d.source, Doc::VALVE);
        // Ours fills a gap, keeps its own source id, and arrives as ONE line whatever it was authored
        // as — every target embeds it in a comment, and two of the three end at a newline.
        let d = doc("ConCommand::ours").unwrap();
        assert_eq!(d.text, "a reading of this build");
        assert_eq!(d.source, "generated");
        // Nothing describes it, which is the majority case: an emitter renders it as it always did.
        assert!(doc("ConCommand::silent").is_none());
    }

    #[test]
    fn prose_reaches_the_emitters_by_the_record_it_belongs_to_never_by_its_name() {
        // The same trap the merge itself refuses, one layer up: an UNJOINED row bears a name that
        // belongs to another module's function, so a view that looked prose up by name would hand it a
        // sentence about code it is not.
        let m = mono(
            json!({ "GetName": { "signature": { "library": "server", "linux": "55" },
                                          "provenance": { "tier": "core", "addr": "0x1" } } }),
        );
        let bindings: Bindings = from(json!({
            "meta": { "game_key": "csgo", "source_build": "b", "pulse": 0, "entity_inputs": 0 },
            "pulse": {}, "entity_inputs": [],
            "vscript": [
                { "name": "GetName", "cpp": "GetName", "class": "C", "library": "server",
                  "ret_raw": 0 },
                { "name": "GetName", "cpp": "GetName", "class": "C", "library": "engine2",
                  "ret_raw": 0 },
            ],
        }));
        let desc: BTreeMap<String, Description> = from(json!({
            "GetName": { "text": "the server one", "source": "generated" }
        }));
        let r = merge(m, None, Some(bindings), None, desc);

        let vs = r.vscript();
        let joined = vs.iter().find(|v| v.library == "server").unwrap();
        assert_eq!(
            joined.doc.as_ref().map(|d| d.text.as_str()),
            Some("the server one")
        );
        let elsewhere = vs.iter().find(|v| v.library == "engine2").unwrap();
        assert!(elsewhere.doc.is_none());

        // The prototype view has no such hazard — it is keyed by the record — so it carries the prose.
        let m2 = mono(json!({ "A::k": { "offset": 1, "provenance": { "tier": "core" } } }));
        let abi: AbiManifest = from(json!({
            "meta": { "game_key": "csgo", "source_build": "b", "counts": {} },
            "functions": { "A::k": { "tier": "core", "matched_by": "exact", "status": "verified" } },
        }));
        let desc2: BTreeMap<String, Description> =
            from(json!({ "A::k": { "text": "t", "source": "derived" } }));
        let man = merge(m2, Some(abi), None, None, desc2).abi_manifest();
        assert_eq!(
            man.functions["A::k"]
                .doc
                .as_ref()
                .map(|d| d.source.as_str()),
            Some("derived")
        );
    }

    #[test]
    fn the_resolved_prose_is_a_view_and_never_reaches_the_artifact() {
        // `doc` is joined at view time from facts the artifact already states. Serializing it would
        // give one sentence two homes, and two homes is how they come to disagree.
        let m = mono(json!({ "A::k": { "offset": 1, "provenance": { "tier": "core" } } }));
        let abi: AbiManifest = from(json!({
            "meta": { "game_key": "csgo", "source_build": "b", "counts": {} },
            "functions": { "A::k": { "tier": "core", "matched_by": "exact", "status": "verified" } },
        }));
        let desc: BTreeMap<String, Description> =
            from(json!({ "A::k": { "text": "t", "source": "generated" } }));
        let r = merge(m, Some(abi), None, None, desc);
        let v = serde_json::to_value(&r).unwrap();
        assert!(v["functions"]["A::k"]["prototype"].get("doc").is_none());
        assert_eq!(v["functions"]["A::k"]["description"]["text"], "t");
        // and the view still produces it from what WAS written
        let back: Rosetta = serde_json::from_value(v).unwrap();
        assert_eq!(back.abi_manifest().functions["A::k"].doc.is_some(), true);
    }

    #[test]
    fn every_declared_row_lands_exactly_once() {
        // The merge must not be a filter: a row either describes a function this build ships, or it is
        // stated as a surface. Nothing the deriver read may fall out between the two.
        let m = mono(
            json!({ "ConCommand::a": { "signature": { "library": "server", "linux": "55" },
                                                "provenance": { "tier": "core" } } }),
        );
        let bindings: Bindings = from(json!({
            "meta": { "game_key": "csgo", "source_build": "b", "pulse": 0, "entity_inputs": 0 },
            "pulse": {},
            "entity_inputs": [{ "input": "Kill", "handler": "nosuchfunction", "library": "server",
                                "addr": "0x1" }],
            "commands": [
                { "name": "a", "library": "server", "flags_raw": "0x0", "form": "direct", "addr": "0x1" },
                { "name": "a", "library": "engine2", "flags_raw": "0x0", "form": "direct", "addr": "0x2" },
                { "name": "b", "library": "server", "flags_raw": "0x0", "form": "direct", "addr": "0x3" },
            ],
            "vscript": [{ "name": "K", "cpp": "nope", "library": "server", "ret_raw": 0 }],
        }));
        let r = merge(m, None, Some(bindings), None, BTreeMap::new());
        let (j, u) = (&r.meta.joined.bindings, &r.surfaces.unjoined);
        assert_eq!((j.entity_input, u.entity_inputs.len()), (0, 1));
        assert_eq!((j.command, u.commands.len()), (1, 2)); // one joined; the other library's + the unknown
        assert_eq!((j.vscript, u.vscript.len()), (0, 1));
    }

    #[test]
    fn a_vtable_located_record_cannot_contradict_a_library_so_it_keeps_the_binding() {
        // A slot names no library, so there is nothing to check the row against — and refusing on
        // absent evidence would drop bindings for every offset-located function.
        let m = mono(json!({ "A::b": { "offset": 3, "provenance": { "tier": "core" } } }));
        let bindings: Bindings = from(json!({
            "meta": { "game_key": "csgo", "source_build": "b", "pulse": 0, "entity_inputs": 0 },
            "pulse": {},
            "entity_inputs": [{ "input": "Kill", "class": "A", "handler": "b",
                                "library": "engine2", "addr": "0x1" }],
        }));
        let r = merge(m, None, Some(bindings), None, BTreeMap::new());
        assert_eq!(r.functions["A::b"].bindings.len(), 1);
    }

    #[test]
    fn a_binding_with_no_function_record_survives_as_a_surface() {
        let m = mono(json!({ "A::b": { "offset": 3, "provenance": { "tier": "core" } } }));
        let bindings: Bindings = from(json!({
            "meta": { "game_key": "csgo", "source_build": "b", "pulse": 0, "entity_inputs": 0 },
            "pulse": {}, "entity_inputs": [],
            "vscript": [{ "name": "Kill", "cpp": "Script_Kill", "library": "server", "ret_raw": 0 }],
        }));
        let r = merge(m, None, Some(bindings), None, BTreeMap::new());
        assert_eq!(r.meta.joined.bindings.vscript, 0);
        assert_eq!(r.surfaces.unjoined.vscript.len(), 1);
        assert_eq!(r.surfaces.unjoined.vscript[0].cpp, "Script_Kill");
    }

    #[test]
    fn the_artifact_reads_back_as_what_was_written() {
        let m = mono(json!({ "A::b": {
            "signature": { "library": "server", "linux": "55" },
            "provenance": { "tier": "core" }, "aliases": ["UTIL::b"] } }));
        let bindings: Bindings = from(json!({
            "meta": { "game_key": "csgo", "source_build": "b", "pulse": 0, "entity_inputs": 0 },
            "pulse": {},
            "entity_inputs": [{ "input": "Kill", "class": "A", "handler": "b", "library": "server",
                                "addr": "0x1" }],
        }));
        let schema: Schema = from(json!({
            "meta": { "game_key": "csgo", "source_build": "b", "typed": 1, "untyped": 0 },
            "classes": { "A": { "m_i": { "offset": 4, "size": 4, "name_hash": 7 } } },
        }));
        let r = merge(m, None, Some(bindings), Some(schema), BTreeMap::new());
        let back: Rosetta = serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap();
        assert_eq!(back.functions["A::b"].tier, Section::Core);
        assert_eq!(back.functions["A::b"].aliases, ["UTIL::b"]);
        assert!(matches!(
            back.functions["A::b"].bindings[..],
            [FunctionBinding::EntityInput { .. }]
        ));
        assert_eq!(back.schema.unwrap().classes["A"]["m_i"].offset, 4);
    }
}

#[cfg(test)]
mod monolith_tests {
    use super::*;

    #[test]
    fn mono_entry_flattens_locator_and_kebabs_tier() {
        let e = MonoEntry {
            locator: Entry {
                signature: None,
                offset: Some(158),
                class: None,
                anchors: Vec::new(),
            },
            abi: None,
            provenance: Provenance {
                source: Some("catalogue".into()),
                ..Provenance::with_tier(Tier::Core)
            },
            validated: Some(true),
            aliases: Vec::new(),
        };
        let v = serde_json::to_value(&e).unwrap();
        assert_eq!(v["offset"], 158); // locator flattened to the top level
        assert!(v.get("signature").is_none()); // None locator field omitted
        assert_eq!(v["provenance"]["tier"], "core"); // kebab-case enum
        assert_eq!(v["provenance"]["source"], "catalogue");
        assert!(v["provenance"].get("confidence").is_none()); // None provenance field skipped
        assert_eq!(v["validated"], true);
    }

    #[test]
    fn experimental_entry_keeps_class_and_serializes_null_validated() {
        let e = MonoEntry {
            locator: Entry {
                signature: None,
                offset: Some(40),
                class: Some("CFoo".into()),
                anchors: Vec::new(),
            },
            abi: None,
            provenance: Provenance {
                confidence: Some("low".into()),
                self_named: Some(false),
                collision: Some(true),
                dead_weight: Some(false),
                ..Provenance::with_tier(Tier::Low)
            },
            validated: None,
            aliases: Vec::new(),
        };
        let v = serde_json::to_value(&e).unwrap();
        assert_eq!(v["class"], "CFoo");
        assert_eq!(v["provenance"]["tier"], "low");
        assert_eq!(v["provenance"]["collision"], true);
        assert_eq!(v["validated"], serde_json::Value::Null); // present as null, not omitted
    }

    #[test]
    fn monolith_round_trips() {
        let mut m = Monolith {
            meta: MonoMeta {
                game_key: "csgo".into(),
                game: "CS2".into(),
                source_build: "2026-07-15_003539".into(),
                version: "cs2-12345-0".into(),
                counts: Counts {
                    core: 1,
                    high_confidence: 0,
                    experimental: 0,
                    unresolved: 1,
                },
                alias_groups: 0,
                aliased_names: 0,
            },
            core: BTreeMap::new(),
            high_confidence: BTreeMap::new(),
            experimental: BTreeMap::new(),
            unresolved: BTreeMap::new(),
        };
        m.core.insert(
            "A::b".into(),
            MonoEntry {
                locator: Entry {
                    signature: Some(Sig {
                        library: "server".into(),
                        linux: "55 48 89 E5".into(),
                    }),
                    offset: None,
                    class: None,
                    anchors: Vec::new(),
                },
                abi: None,
                provenance: Provenance {
                    source: Some("catalogue".into()),
                    ..Provenance::with_tier(Tier::Core)
                },
                validated: Some(true),
                aliases: Vec::new(),
            },
        );
        m.unresolved.insert(
            "C::d".into(),
            Unresolved {
                reason: "sig-drifted".into(),
                detail: "no unique/recovered signature in target".into(),
            },
        );
        let s = serde_json::to_string_pretty(&m).unwrap();
        let back: Monolith = serde_json::from_str(&s).unwrap();
        assert_eq!(back.core.len(), 1);
        assert_eq!(
            back.core["A::b"]
                .locator
                .signature
                .as_ref()
                .unwrap()
                .library,
            "server"
        );
        assert_eq!(back.unresolved["C::d"].reason, "sig-drifted");
        assert_eq!(back.meta.counts.unresolved, 1);
    }
}
