//! Emitters: render the canonical [`Gamedata`] model into each framework's on-disk format. Adding a
//! new consumer (SourceMod, a diff, a language SDK) is a new `impl GamedataEmitter` here — the
//! derivation never changes. This module depends only on `model` + serde, NOT on the deriver, so a
//! standalone `source2rosetta-gen` binary can link just this to turn a published model JSON into files.

use crate::model::{
    self, Entry, Gamedata, MonoEntry, Monolith, Schema, Tier, TierSelect, VScriptBinding, one_line,
};
use serde_json::{Map, Value, json};
use std::collections::BTreeMap;

/// Render the monolith as the CounterStrikeSharp combined `gamedata.json`: the guaranteed `core` section, a
/// `//` banner, then the extrapolated section (the tiers `select` includes beyond core), both key-sorted with
/// compact locators. At `TierSelect::HighConfidence` the output is exactly core + high_confidence; other
/// selections extend the extrapolated section.
///
/// Drops `validated == Some(false)` entries, matching [`Monolith::select`]: an entry live validation
/// confidently rejected must not reach a consumer under a banner claiming it resolves on a running server.
/// (Pre-validation every `validated` is `None`, so there this is a no-op.)
pub fn render_monolith_cssharp(mono: &Monolith, select: TierSelect) -> String {
    // the extrapolated section: the tiers beyond core that `select` includes, merged key-sorted. The tiers are
    // documented disjoint and are so for anything this crate derives, but an externally-supplied monolith need
    // not be — so keep the more-confident tier on collision (`select`'s rule) and subtract `core`, which would
    // otherwise emit the same JSON key in both sections.
    let mut extra: std::collections::BTreeMap<&String, &MonoEntry> =
        std::collections::BTreeMap::new();
    let mut sources: Vec<&std::collections::BTreeMap<String, MonoEntry>> = Vec::new();
    if select != TierSelect::Core {
        sources.push(&mono.high_confidence);
    }
    if select == TierSelect::Experimental {
        sources.push(&mono.experimental);
    }
    for m in sources {
        for (name, e) in m {
            if e.validated == Some(false) || mono.core.contains_key(name) {
                continue;
            }
            extra.entry(name).or_insert(e);
        }
    }
    let n_self = extra
        .values()
        .filter(|e| e.provenance.tier == Tier::SelfNamed)
        .count();
    // BOTH labels count: dictionary hits are tagged `DictExact` in the high-confidence tier and
    // `Corroborated` in the experimental tier, so counting only one variant misses genuinely corroborated
    // entries when the experimental tier is selected.
    let n_dict = extra
        .values()
        .filter(|e| e.provenance.tier.is_dict_corroborated())
        .count();
    let n_byval = extra
        .values()
        .filter(|e| e.provenance.by_value == Some(true))
        .count();

    let line = |name: &str, e: &MonoEntry| {
        format!(
            "  {}: {}",
            serde_json::to_string(name).unwrap_or_default(),
            serde_json::to_string(&locator_value(&e.locator)).unwrap_or_default()
        )
    };
    let core: Vec<(&String, &MonoEntry)> = mono
        .core
        .iter()
        .filter(|(_, e)| e.validated != Some(false))
        .collect();
    let core_lines: Vec<String> = core.iter().map(|(n, e)| line(n, e)).collect();
    let extra_lines: Vec<String> = extra.iter().map(|(n, e)| line(n, e)).collect();
    let total = core.len() + extra.len();
    let header = format!(
        "// ============================================================================\n\
         // source2rosetta combined gamedata  ({version})\n\
         // {} guaranteed + {} extrapolated = {total} functions.\n\
         // Auto-derived from the stripped {} server libraries, live-validated against a running server.\n\
         // (JSON with // comments — CS#'s loader and source2rosetta's own reader both tolerate them.)\n\
         // ============================================================================",
        core.len(),
        extra.len(),
        mono.meta.game,
        version = mono.meta.version,
    );
    let divider = [
        "  // ==========================================================================".to_string(),
        format!("  // EXTRAPOLATED NAMES  ({} total: {n_self} self-named, {n_dict} dict-corroborated; {n_byval} by-value)", extra.len()),
        "  // ---------------------------------------------------------------------------".to_string(),
        "  // Everything BELOW is AI-extrapolated from the stripped binary. Each LOCATOR".to_string(),
        "  // (sig / vtable offset) is live-validated — it resolves to real executable code".to_string(),
        "  // on a running server. The NAME is a best-effort label, not a symbol Valve".to_string(),
        "  // shipped (the binary is stripped); the exact C++ prototype (arg/return types)".to_string(),
        "  // is unrecoverable, so confirm the call signature yourself. Trust tiers + a".to_string(),
        "  // by-value (unsafe-to-naive-call) flag live in the provenance sidecar. Above = guaranteed.".to_string(),
        "  // ==========================================================================".to_string(),
    ]
    .join("\n");
    let mut s = header;
    s.push_str("\n{\n");
    s.push_str(&core_lines.join(",\n"));
    if !core_lines.is_empty() && !extra_lines.is_empty() {
        s.push(','); // the last core entry needs a comma — the extrapolated section follows the banner
    }
    s.push('\n');
    s.push_str(&divider);
    s.push('\n');
    s.push_str(&extra_lines.join(",\n"));
    s.push_str("\n}\n");
    s
}

/// Render a derived gamedata model to one output format's text.
pub trait GamedataEmitter {
    fn id(&self) -> &'static str;
    fn render(&self, gd: &Gamedata) -> String;
}

/// The canonical CS#-gamedata locator for one entry: an object carrying `"signatures":{library,linux}`
/// when it has a signature and/or `"offsets":{linux}` when it has a vtable slot. This is the on-disk shape
/// the cssharp `gamedata.json` and the deriver's combined/experimental files all share, so every producer
/// builds it through this ONE function instead of an ad-hoc `json!`. Keys serialize sorted (serde_json's
/// default `Map`), so an entry carrying both a signature and an offset is deterministic.
pub fn locator_value(e: &Entry) -> Value {
    let mut obj = Map::new();
    if let Some(s) = &e.signature {
        obj.insert(
            "signatures".into(),
            json!({ "library": s.library, "linux": s.linux }),
        );
    }
    if let Some(o) = e.offset {
        obj.insert("offsets".into(), json!({ "linux": o }));
    }
    Value::Object(obj)
}

/// The inverse of [`locator_value`]: parse the on-disk cssharp locator shape
/// (`{"signatures":{library,linux}}` and/or `{"offsets":{linux}}`) back into a typed [`Entry`]. Lives here,
/// beside the forward writer, so the round-trip is single-sourced in `core` instead of hand-rolled in the
/// deriver. Tolerant: a missing `signatures.library` defaults to `server`, a
/// missing `signatures.linux` to empty, and an entry may carry a signature and/or an offset (or neither).
pub fn entry_from_value(v: &Value) -> Entry {
    let signature = v.get("signatures").map(|s| crate::model::Sig {
        library: s
            .get("library")
            .and_then(Value::as_str)
            .unwrap_or("server")
            .to_string(),
        linux: s
            .get("linux")
            .and_then(Value::as_str)
            .unwrap_or_default()
            .to_string(),
    });
    let offset = v
        .get("offsets")
        .and_then(|o| o.get("linux"))
        .and_then(Value::as_i64);
    // The cssharp locator shape has no anchor field, so a round-tripped entry carries none. That is a
    // boundary, not a loss: every caller of this function reads `.offset` off the result, and the anchor
    // path to an emitter runs through `Monolith::select`, which copies the whole `Entry`.
    Entry {
        signature,
        offset,
        class: None,
        anchors: Vec::new(),
    }
}

/// Look up an emitter by its `--format` id.
pub fn by_id(id: &str) -> Option<Box<dyn GamedataEmitter>> {
    match id {
        "cssharp" => Some(Box::new(CsSharp)),
        "metamod" => Some(Box::new(Metamod)),
        "modsharp" => Some(Box::new(ModSharp)),
        "swiftly" => Some(Box::new(Swiftly)),
        "plugify" => Some(Box::new(Plugify)),
        "model" => Some(Box::new(ModelJson)),
        _ => None,
    }
}

/// Every gamedata emitter id `by_id` accepts (keep in sync with it), named like its three sibling
/// families. NOT the `--format` list — see [`FORMAT_IDS`], which names CONSUMERS rather than emitters.
pub const GAMEDATA_FORMAT_IDS: &[&str] = &[
    "cssharp", "metamod", "modsharp", "swiftly", "plugify", "model",
];

/// The selected tiers, flattened into one name -> locator map and serialized. Format-neutral: the shape
/// every other emitter here renders FROM, for a consumer whose framework is not one of the five.
///
/// NOT a round trip, and not the canonical model. `--from` parses a `Monolith` (meta + four tiers); this
/// emits a tier-SELECTED `Gamedata` with no meta, so feeding its output back in fails on the missing
/// `meta`. That is the useful behaviour — re-emitting the monolith verbatim would just copy the input
/// file — but the doc used to claim the opposite, so: the build identity lives in `manifest.json` and in
/// the `cssharp` banner, never here.
pub struct ModelJson;

impl GamedataEmitter for ModelJson {
    fn id(&self) -> &'static str {
        "model"
    }

    fn render(&self, gd: &Gamedata) -> String {
        serde_json::to_string_pretty(gd).unwrap_or_default()
    }
}

/// CounterStrikeSharp `gamedata.json`: `{ name: { "signatures": {library,linux} | "offsets": {linux} } }`.
/// Built through serde_json's key-sorted Map for deterministic output.
pub struct CsSharp;

impl GamedataEmitter for CsSharp {
    fn id(&self) -> &'static str {
        "cssharp"
    }

    fn render(&self, gd: &Gamedata) -> String {
        let mut doc = Map::new();
        for (name, e) in &gd.entries {
            doc.insert(name.clone(), locator_value(e));
        }
        serde_json::to_string_pretty(&Value::Object(doc)).unwrap_or_default()
    }
}

/// Metamod/SourceMod `*.games.txt` (Valve KeyValues): `Games { csgo { Signatures{..} Offsets{..} } }`.
/// Signature bytes become `\xAB` escapes with `\x2A` for wildcards.
pub struct Metamod;

/// "55 48 ? E5" -> "\x55\x48\x2A\xE5". Handles every wildcard token `sig::Pattern` accepts (`?`/`??`/`*`).
///
/// LIMITATION: SourceMod/Metamod's wildcard byte IS 0x2A (`*`), so a signature with a *fixed* 0x2A
/// byte is inherently ambiguous in this format — it renders as `\x2A` and Metamod reads it as a
/// wildcard, widening the pattern. That's a constraint of the VDF signature format itself (real
/// SourceMod gamedata shares it), not something the emitter can encode away; a fully Metamod-safe
/// pattern would need make_sig to avoid depending on a fixed 0x2A byte for uniqueness.
fn vdf_pattern(spacehex: &str) -> String {
    spacehex
        .split_whitespace()
        .map(|t| match t {
            "?" | "??" | "*" => "\\x2A".to_string(),
            b => format!("\\x{}", b.to_uppercase()),
        })
        .collect()
}

impl GamedataEmitter for Metamod {
    fn id(&self) -> &'static str {
        "metamod"
    }

    fn render(&self, gd: &Gamedata) -> String {
        let mut sigs = String::new();
        let mut offs = String::new();
        for (name, e) in &gd.entries {
            if let Some(s) = &e.signature {
                sigs.push_str(&format!(
                    "\t\t\t\"{name}\"\n\t\t\t{{\n\t\t\t\t\"library\"\t\"{}\"\n\t\t\t\t\"linux\"\t\"{}\"\n\t\t\t}}\n",
                    s.library,
                    vdf_pattern(&s.linux),
                ));
            }
            if let Some(o) = e.offset {
                offs.push_str(&format!(
                    "\t\t\t\"{name}\"\n\t\t\t{{\n\t\t\t\t\"linux\"\t\"{o}\"\n\t\t\t}}\n"
                ));
            }
        }
        let key = &gd.game_key;
        format!(
            "\"Games\"\n{{\n\t\"{key}\"\n\t{{\n\t\t\"Signatures\"\n\t\t{{\n{sigs}\t\t}}\n\t\t\"Offsets\"\n\t\t{{\n{offs}\t\t}}\n\t}}\n}}\n"
        )
    }
}

/// ModSharp `*.games.jsonc`: `{ "Addresses": {sig}, "VFuncs": {offset} }`. Signatures carry a `library`;
/// VFuncs are a bare `linux` slot index. Linux-only (windows isn't derived from a `.so`).
pub struct ModSharp;

impl GamedataEmitter for ModSharp {
    fn id(&self) -> &'static str {
        "modsharp"
    }

    fn render(&self, gd: &Gamedata) -> String {
        let (mut addresses, mut vfuncs) = (Map::new(), Map::new());
        for (name, e) in &gd.entries {
            if let Some(s) = &e.signature {
                let mut row = Map::new();
                row.insert("library".into(), Value::String(s.library.clone()));
                row.insert("linux".into(), Value::String(s.linux.clone()));
                // ModSharp's own `refs` feature: a string this function references, which its loader
                // resolves per build. Emitted BESIDE `linux` exactly as their hand-written gamedata does,
                // so a build whose byte pattern drifted can still be located.
                if !e.anchors.is_empty() {
                    row.insert("refs".into(), json!({ "strings": e.anchors }));
                }
                addresses.insert(name.clone(), Value::Object(row));
            }
            if let Some(o) = e.offset {
                let mut row = Map::new();
                row.insert("linux".into(), json!(o));
                // `refs.vtable` is ModSharp's own key for "the class whose vtable holds this slot", and a
                // slot index without it is not a locator at all.
                if let Some(c) = &e.class {
                    row.insert("refs".into(), json!({ "vtable": c }));
                }
                vfuncs.insert(name.clone(), Value::Object(row));
            }
            // An anchor with no signature is still a usable locator for ModSharp — `refs` alone is how
            // several of their own entries are written. Dropping these would discard the only thing we
            // know about a function whose byte pattern did not resolve.
            if e.signature.is_none() && !e.anchors.is_empty() {
                addresses.insert(name.clone(), json!({ "refs": { "strings": e.anchors } }));
            }
        }
        let doc = json!({ "Addresses": addresses, "VFuncs": vfuncs });
        serde_json::to_string_pretty(&doc).unwrap_or_default()
    }
}

/// SwiftlyS2 `signatures.jsonc`: `{ name: {"lib": lib, "linux": sig} }`. Swiftly keeps offsets in a
/// separate `offsets.jsonc`; this emits the signatures file (the bulk of a gamedata).
pub struct Swiftly;

impl GamedataEmitter for Swiftly {
    fn id(&self) -> &'static str {
        "swiftly"
    }

    fn render(&self, gd: &Gamedata) -> String {
        let mut doc = Map::new();
        for (name, e) in &gd.entries {
            if let Some(s) = &e.signature {
                doc.insert(name.clone(), json!({ "lib": s.library, "linux": s.linux }));
            }
        }
        serde_json::to_string_pretty(&Value::Object(doc)).unwrap_or_default()
    }
}

/// Plugify (s2sdk) `gamedata.jsonc`: `{ "csgo": { "Signatures": {..}, "Offsets": {..} } }`. Uses the
/// `linuxsteamrt64` platform key; signatures carry a `library`.
pub struct Plugify;

impl GamedataEmitter for Plugify {
    fn id(&self) -> &'static str {
        "plugify"
    }

    fn render(&self, gd: &Gamedata) -> String {
        let (mut sigs, mut offs) = (Map::new(), Map::new());
        for (name, e) in &gd.entries {
            if let Some(s) = &e.signature {
                sigs.insert(
                    name.clone(),
                    json!({ "library": s.library, "linuxsteamrt64": s.linux }),
                );
            }
            if let Some(o) = e.offset {
                offs.insert(name.clone(), json!({ "linuxsteamrt64": o }));
            }
        }
        let mut doc = Map::new();
        doc.insert(
            gd.game_key.clone(),
            json!({ "Signatures": sigs, "Offsets": offs }),
        );
        serde_json::to_string_pretty(&Value::Object(doc)).unwrap_or_default()
    }
}

// ============================ SCHEMA / NETVAR emitters ============================
// The typed schema (`netvars-<game>.json`, `model::Schema`) has its own emitter family, parallel to
// `GamedataEmitter`: turn the class->field->offset/type surface into a consumable SDK or netvar file.
// Adding a language SDK or a framework netvar format is a new `impl SchemaEmitter` here.

/// Render `model::Schema` (the typed netvars) into a consumer format (a language SDK, a netvar map).
pub trait SchemaEmitter {
    fn id(&self) -> &'static str;
    fn render(&self, schema: &Schema) -> String;
}

/// A Source-2 schema type as its C# spelling: the primitive scalars map to C# built-ins; everything else
/// (Source-2 math types, handles, `CUtl*`, enums, templates) is kept verbatim as the ground-truth type name
/// — a fully-typed SDK for all 1,400+ custom types isn't ours to define, and the raw name is the honest hint.
fn cs_type(ty: &str) -> &str {
    match ty {
        "int8" | "char8" => "sbyte",
        "uint8" => "byte",
        "int16" => "short",
        "uint16" => "ushort",
        "int32" => "int",
        "uint32" => "uint",
        "int64" => "long",
        "uint64" => "ulong",
        "float32" => "float",
        "float64" => "double",
        "bool" => "bool",
        other => other,
    }
}

/// A schema name (class or nested-type) as a valid C# identifier: nested `Outer::Inner` and any other
/// non-identifier char collapse to `_`. The original name rides along in an XML-doc comment when it changed.
fn cs_ident(name: &str) -> String {
    let id: String = name
        .chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || c == '_' {
                c
            } else {
                '_'
            }
        })
        .collect();
    if id.chars().next().is_some_and(|c| c.is_ascii_digit()) {
        format!("_{id}")
    } else {
        id
    }
}

/// A typed C# SDK: one `static class` per schema class, each field a `const int <name> = 0x<offset>;` tagged
/// with its C# / Source-2 type. Framework-neutral (offsets + types, no runtime-read assumption), complete
/// (every field, every type), deterministic (the schema's BTreeMaps sort classes then fields).
pub struct CsSdk;

impl SchemaEmitter for CsSdk {
    fn id(&self) -> &'static str {
        "cs-sdk"
    }

    fn render(&self, s: &Schema) -> String {
        let m = &s.meta;
        let mut out = String::new();
        out.push_str(&format!(
            // The artifact is named by the GAME TOKEN (`cs2`), which the schema does not carry — it knows
            // its content key (`csgo`). So the command is spelled generically rather than interpolated
            // into something that does not exist.
            "// <auto-generated> source2rosetta — {} build {}. Source-2 SchemaSystem field offsets.\n\
             // {} classes, {} typed fields, {} enums. Regenerate: source2rosetta-gen --from rosetta-<game>.json --format cs-sdk\n\
             namespace Source2.Schema;\n",
            m.game_key,
            m.source_build,
            s.classes.len(),
            m.typed,
            s.enums.len()
        ));
        // Enums first: a field offset is only half the story, and the C# side wants the type in scope
        // before the classes that reference it. Emitted with the engine's own underlying width, so a
        // byte-sized enum round-trips through interop at the right size.
        for (name, e) in &s.enums {
            let ident = cs_ident(name);
            out.push('\n');
            if ident != *name {
                out.push_str(&format!("/// <summary><c>{name}</c></summary>\n"));
            }
            out.push_str(&format!(
                "public enum {ident} : {}\n{{\n",
                cs_enum_base(e.size)
            ));
            // Values are NOT unique (aliases share one), which C# allows; names are, which it requires.
            for v in &e.values {
                out.push_str(&format!("    {} = {},\n", cs_ident(&v.name), v.value));
            }
            out.push_str("}\n");
        }
        for (cls, fields) in &s.classes {
            let ident = cs_ident(cls);
            out.push('\n');
            if ident != *cls {
                out.push_str(&format!(
                    "/// <summary><c>{cls}</c> — {} fields</summary>\n",
                    fields.len()
                ));
            } else {
                out.push_str(&format!(
                    "/// <summary>{cls} — {} fields</summary>\n",
                    fields.len()
                ));
            }
            out.push_str(&format!("public static class {ident}\n{{\n"));
            for (fname, f) in fields {
                out.push_str(&format!(
                    "    public const int {fname} = 0x{:X}; // {}\n",
                    f.offset,
                    cs_type(&f.ty)
                ));
            }
            out.push_str("}\n");
        }
        out
    }
}

/// The C# integer type for an enum's underlying width. Signed throughout: enumerators are read as `i64`
/// and negative values are ordinary (`GEAR_SLOT_INVALID = -1`), so an unsigned base would not hold them.
fn cs_enum_base(size: u8) -> &'static str {
    match size {
        1 => "sbyte",
        2 => "short",
        8 => "long",
        _ => "int",
    }
}

/// Framework-neutral netvar map: `{ class: { field: offset } }` — the raw offset table any tool/framework
/// can consume without the SDK's C# packaging.
pub struct NetvarsJson;

impl SchemaEmitter for NetvarsJson {
    fn id(&self) -> &'static str {
        "netvars"
    }

    fn render(&self, s: &Schema) -> String {
        let mut doc = Map::new();
        for (cls, fields) in &s.classes {
            let mut fm = Map::new();
            for (fname, f) in fields {
                fm.insert(fname.clone(), json!(f.offset));
            }
            doc.insert(cls.clone(), Value::Object(fm));
        }
        serde_json::to_string_pretty(&Value::Object(doc)).unwrap_or_default()
    }
}

/// Look up a schema emitter by its `--format` id.
pub fn schema_by_id(id: &str) -> Option<Box<dyn SchemaEmitter>> {
    match id {
        "cs-sdk" => Some(Box::new(CsSdk)),
        "netvars" => Some(Box::new(NetvarsJson)),
        _ => None,
    }
}

/// Every schema `--format` id `schema_by_id` accepts (keep in sync with it).
pub const SCHEMA_FORMAT_IDS: &[&str] = &["cs-sdk", "netvars"];

// ============================ ABI / PROTOTYPE emitters ============================
// The third emitter family. `GamedataEmitter` answers "where is this function"; `SchemaEmitter` answers
// "what shape is this class"; this one answers "how do I CALL it" — the declared parameter and return
// types from `abi-<game>.json`, judged against the footprint measured in the build being shipped.
//
// Every framework here consumes it differently, and the difference is not cosmetic: CounterStrikeSharp
// and ModSharp need generated SOURCE because their type lists are compile-time (C# generics, a Roslyn
// generator); Metamod plugins are C++ and take the DECLARED types verbatim; Swiftly and Plugify take a
// RUNTIME type list and so consume a data file. What they share is the gate below, which is where all
// the honesty lives — so it is written once.

/// A declared type reduced to the ABI class a caller must actually load. Deliberately coarse: this is
/// what the SysV call frame distinguishes, not what C++ distinguishes.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum AbiType {
    /// Any pointer or reference — SysV moves them all in one integer register.
    Ptr,
    /// `char const*`: a pointer, but every target here can marshal it as a string.
    Str,
    Bool,
    I8,
    U8,
    I16,
    U16,
    I32,
    U32,
    I64,
    U64,
    F32,
    F64,
}

impl AbiType {
    /// The C# spelling. `nint` rather than `IntPtr` because the generated file is modern C#, and both
    /// `MemoryFunction*` families accept it.
    fn cs(self) -> &'static str {
        match self {
            AbiType::Ptr => "nint",
            AbiType::Str => "string",
            AbiType::Bool => "bool",
            AbiType::I8 => "sbyte",
            AbiType::U8 => "byte",
            AbiType::I16 => "short",
            AbiType::U16 => "ushort",
            AbiType::I32 => "int",
            AbiType::U32 => "uint",
            AbiType::I64 => "long",
            AbiType::U64 => "ulong",
            AbiType::F32 => "float",
            AbiType::F64 => "double",
        }
    }

    /// The spelling usable inside a `delegate* unmanaged<…>`, which admits only unmanaged types.
    /// `string` is managed and would not compile there; ModSharp's own generator makes the same
    /// substitution (`Utils/Extensions.cs`), so a caller passes the UTF-8 bytes it already has.
    fn cs_unmanaged(self) -> &'static str {
        match self {
            AbiType::Str => "sbyte*",
            other => other.cs(),
        }
    }

    /// Whether CounterStrikeSharp's marshaller can map this class at all.
    ///
    /// Its `Type -> DataType` table (`Modules/Memory/DataType.cs`) has no `ushort` entry, so
    /// `typeof(ushort).ToDataType()` returns null: CS# logs "Error retrieving data type" and builds the
    /// function from an argument list with a hole in it. Emitting one would be generating a call site
    /// that cannot work, so the CS# target drops those functions — one per game today. They are
    /// unaffected in the other four targets, which have their own type vocabularies.
    fn cs_marshalable(self) -> bool {
        !matches!(self, AbiType::U16)
    }

    /// Swiftly's single-character type descriptor (`src/memory/hooks/vfunctions.cpp`): the string it
    /// splits per argument to drive `dcArg*`. Strings are pointers to it.
    fn swiftly(self) -> char {
        match self {
            AbiType::Ptr | AbiType::Str => 'p',
            AbiType::Bool => 'b',
            AbiType::I8 | AbiType::U8 => 'c',
            AbiType::I16 | AbiType::U16 => 's',
            AbiType::I32 | AbiType::U32 => 'i',
            AbiType::I64 | AbiType::U64 => 'l',
            AbiType::F32 => 'f',
            AbiType::F64 => 'd',
        }
    }
}

/// Map ONE declared C++ type onto its ABI class, or `None` when the declaration does not settle it.
///
/// `None` is the important return. A named enum or a by-value aggregate has a width this artifact does
/// not state, and guessing it produces a call that resolves and then loads the wrong register width —
/// so the whole function is dropped instead. That is why the emittable set is much smaller than the
/// verified set, and it is the right trade.
pub fn abi_type_of(decl: &str) -> Option<AbiType> {
    let t = decl.replace("const", " ");
    let t = t.split_whitespace().collect::<Vec<_>>().join(" ");
    if t.contains('*') || t.contains('&') {
        // `char*` is the one pointer every target can marshal as a string.
        let base = t.replace(['*', '&'], " ");
        let base = base.trim();
        return Some(if base == "char" {
            AbiType::Str
        } else {
            AbiType::Ptr
        });
    }
    Some(match t.as_str() {
        "bool" => AbiType::Bool,
        "char" | "signed char" | "int8_t" => AbiType::I8,
        "unsigned char" | "uint8_t" | "byte" => AbiType::U8,
        "short" | "short int" | "int16_t" => AbiType::I16,
        "unsigned short" | "unsigned short int" | "uint16_t" => AbiType::U16,
        "int" | "int32_t" => AbiType::I32,
        "unsigned" | "unsigned int" | "uint32_t" => AbiType::U32,
        // LP64: the deriver targets Linux, where `long` is 64 bits. Mapping it to 32 would truncate.
        "long" | "long int" | "long long" | "int64_t" | "ssize_t" | "intptr_t" | "ptrdiff_t" => {
            AbiType::I64
        }
        "unsigned long" | "unsigned long long" | "uint64_t" | "size_t" | "uintptr_t" => {
            AbiType::U64
        }
        "float" => AbiType::F32,
        "double" => AbiType::F64,
        _ => return None,
    })
}

/// What is known about a function's return.
///
/// The third case is the one that needs justifying. Requiring a DECLARED return would be the strictest
/// rule, and it was measured before being rejected: it leaves 116 of 763 verified CS2 functions and
/// **2 of 2,360 on Dota**, because Dota's declarations come from Itanium-mangled symbols (which cannot
/// encode a return) and from ModSharp (which is CS2-only). A feature that emits two functions for a game
/// is not a feature.
///
/// Emitting them is safe in the direction that matters: the ARGUMENTS are what a caller loads, and those
/// are verified. Reading the return register afterwards cannot corrupt anything — if the callee returned
/// nothing, the value is meaningless, not dangerous. What must not happen is a consumer mistaking a
/// meaningless value for a typed one, so `Undeclared` is carried through to the output and every emitter
/// marks it: prose in the generated source, a machine-readable flag in the data formats.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Ret {
    /// The source declared `void`.
    Void,
    /// The source declared a type this maps.
    Declared(AbiType),
    /// NOBODY declared one. The value is whatever the return register holds; the measured class travels
    /// alongside as a hint and is explicitly NOT a type (only ~12% of known-void functions measure as
    /// void, so it cannot be treated as one).
    Undeclared,
}

impl Ret {
    /// How the return reads in a PROTOTYPE line. An undeclared return is spelled as such rather than as
    /// its measured class: `-> ret=int` in a prototype invites reading `ret=int` as a type name.
    fn prototype(self, source: &str) -> &str {
        match self {
            Ret::Undeclared => "<undeclared>",
            _ => source,
        }
    }
}

/// A language-safe identifier derived from the gamedata key (`CBaseEntity::SetAbsOrigin` ->
/// `CBaseEntity_SetAbsOrigin`). Every one of the five targets pastes this straight into source — a C#
/// field name, a C++ typedef, a descriptor key — so it has to be valid in all of them.
///
/// Collapsing each offending character to `_` is NOT enough, and the shipped output proved it twice.
/// `CEconItemView::operator=` emitted `CEconItemView_operator=`, which is a syntax error in both C# and
/// C++; and the `+bugvoice` / `-bugvoice` console-command pair collapses to ONE identifier, which is a
/// duplicate definition. So anything outside `[A-Za-z0-9_]` becomes `_` followed by its hex byte, which
/// keeps the mapping injective. It costs readability on roughly three names in 1,800, and the true key
/// rides along in the doc comment above every emitted line.
fn call_ident(key: &str) -> String {
    let mut out = String::with_capacity(key.len());
    for c in key.replace("::", "_").chars() {
        if c.is_ascii_alphanumeric() || c == '_' {
            out.push(c);
        } else {
            // A non-ASCII character is several bytes; escaping each keeps the result ASCII.
            let mut buf = [0u8; 4];
            for b in c.encode_utf8(&mut buf).as_bytes() {
                out.push_str(&format!("_{b:02X}"));
            }
        }
    }
    // An identifier may not start with a digit in either language.
    if out.starts_with(|c: char| c.is_ascii_digit()) {
        out.insert(0, '_');
    }
    out
}

/// One prototype reduced to something callable: every type resolved, the receiver settled.
pub struct CallShape<'a> {
    /// The gamedata key — what the framework looks the address up by.
    pub key: &'a str,
    /// A language-safe identifier derived from the key (`CBaseEntity::SetAbsOrigin` -> `CBaseEntity_SetAbsOrigin`).
    pub ident: String,
    /// Argument classes in order, RECEIVER INCLUDED where the function has one.
    pub params: Vec<AbiType>,
    /// The declared C++ parameter list, RECEIVER INCLUDED — so it describes the same call frame
    /// `params` does. Where the declaration came from a mangled symbol `this` is invisible, so the
    /// receiver is prepended here as `<Class>*`, read off the key; see `receiver_implied`.
    pub declared: Vec<String>,
    /// The receiver is not something the DECLARATION said — it was settled by the vtable slot or by the
    /// measurement, and prepended to `declared`. A consumer that wants only what a source actually
    /// wrote can skip element 0 on these.
    pub receiver_implied: bool,
    pub ret: Ret,
    /// The return exactly as the manifest states it: a declared C++ type, or the measured `ret=` class.
    pub ret_source: &'a str,
    /// The declaration passes registers the callee never reads (`AbiStatus::LowerBound`). Safe to call —
    /// an unread register costs nothing — but it is NOT the same claim as a footprint that matches
    /// exactly, so every output says so.
    pub lower_bound: bool,
    /// Set when the function is reached through a vtable slot rather than an address. The two forms are
    /// not interchangeable in any of the five targets: one resolves a signature, the other indexes the
    /// object's own vtable, and binding a slot through the signature path looks up a key that is not in
    /// the signatures section at all.
    pub vtable: Option<i64>,
    pub provenance: &'a [String],
    /// What the function is FOR, where anything says — see [`model::FunctionRecord::doc`]. The one
    /// field here that is not about the call frame, and the reason a generated call site is readable:
    /// an author hovering `CBaseEntity_Kill` sees a sentence rather than a type list.
    pub doc: Option<&'a model::Doc>,
}

impl CallShape<'_> {
    /// The class a `Class::Method` key names, or `None` for an unqualified one.
    fn owner(key: &str) -> Option<&str> {
        key.rfind("::").map(|i| &key[..i])
    }

    /// `Class::Method(args) -> ret`, in the receiver-inclusive form every target's type list uses. One
    /// spelling so the human-readable line and the machine-readable list cannot describe different calls.
    fn prototype(&self) -> String {
        format!(
            "{}({}) -> {}",
            self.key,
            self.declared.join(", "),
            self.ret.prototype(self.ret_source)
        )
    }
}

/// Every entry of the manifest that can be turned into a call, and nothing else.
///
/// The gate, in order, and each clause is load-bearing:
///
/// 1. **`status` is `verified` or `lower-bound`.** A `mismatch` provably does not describe this build;
///    `unverified` has no measurement behind it; `ambiguous` has unresolved overloads; `return-only` has
///    no parameter list. Emitting any of those would hand a consumer a call the deriver already knows
///    not to trust. `lower-bound` is different in kind and is emitted: the declaration passes registers
///    the callee never reads and contradicts it in NO register class, so calling through one loads a
///    register nobody reads. That is safe in the direction that matters, the same argument this artifact
///    already makes for an undeclared return — and it is marked in every output rather than passed off
///    as fully verified.
/// 2. **The receiver must be settled**, by one of three things, and each is evidence rather than a
///    guess. Getting this wrong shifts every argument by one register — a call that resolves, passes
///    validation, and corrupts state, which is the exact failure this project forbids.
///    - `params_complete`: the declaration names the receiver, so it is already `params[0]`.
///    - a live-validated **vtable slot** (`vtable`): a slot is only reachable through an object, so the
///      receiver is proven by HOW the function is located rather than by what it reads. This is the
///      route that matters most, because the measurement is systematically blind here — a getter that
///      returns a constant never touches `this`, and backward liveness reads that as no argument at
///      all. 203 CS2 and 144 Dota functions are exactly that shape.
///    - the **measurement** independently agreeing that there is one more integer argument than the
///      declaration accounts for.
/// 3. **Every parameter must map** onto an ABI class (see [`abi_type_of`]).
/// 4. **A DECLARED return that cannot be represented drops the function**, because downgrading a real
///    declaration to "unknown" would lose information the source actually had. A function nobody
///    declared a return for is kept and marked `Ret::Undeclared` — see [`Ret`] for why.
pub fn callable_shapes(m: &crate::model::AbiManifest) -> Vec<CallShape<'_>> {
    let mut out = Vec::new();
    for (name, e) in &m.functions {
        let lower_bound = match e.status {
            crate::model::AbiStatus::Verified => false,
            crate::model::AbiStatus::LowerBound => true,
            _ => continue,
        };
        let Some(params) = e.params.as_deref() else {
            continue;
        };
        let ret_source = e.ret.as_deref().unwrap_or("ret=?");
        let Some(mapped) = params
            .iter()
            .map(|p| abi_type_of(p))
            .collect::<Option<Vec<_>>>()
        else {
            continue;
        };

        // Clause 2, in evidence order: what the declaration states, then what the locator proves, then
        // what the measurement can corroborate.
        let int_declared = mapped
            .iter()
            .filter(|t| !matches!(t, AbiType::F32 | AbiType::F64))
            .count();
        let mut full = mapped;
        let mut declared: Vec<String> = params.to_vec();
        let receiver_implied = e.params_complete != Some(true);
        if receiver_implied {
            let Some(owner) = CallShape::owner(name) else {
                continue; // unqualified: nothing names a class, so nothing can name a receiver
            };
            let proven = e.vtable.is_some()
                || e.derived.as_ref().map(|d| d.int as usize) == Some((int_declared + 1).min(6));
            if !proven {
                continue;
            }
            full.insert(0, AbiType::Ptr);
            // Spelled as the owning class rather than `void*`: the key already names it, and a C++
            // consumer that gets `void*` has to rediscover what it may pass.
            declared.insert(0, format!("{owner}*"));
        }
        // A vtable slot is entered through the object, so its first argument IS the receiver and is a
        // pointer. A complete declaration that says otherwise contradicts the locator; the two cannot
        // both be describing this function, and every vtable-form target below would bind the receiver
        // at the wrong width.
        if e.vtable.is_some() && full.first() != Some(&AbiType::Ptr) {
            continue;
        }

        let ret = if ret_source.starts_with("ret=") {
            Ret::Undeclared
        } else if ret_source == "void" {
            Ret::Void
        } else {
            match abi_type_of(ret_source) {
                Some(t) => Ret::Declared(t),
                // A by-value aggregate or an enum of unstated width: the DECLARATION exists and this
                // cannot represent it, which is not the same as nobody declaring one. Drop it rather
                // than silently downgrade a real declaration to "unknown".
                None => continue,
            }
        };

        out.push(CallShape {
            key: name,
            ident: call_ident(name),
            params: full,
            declared,
            receiver_implied,
            ret,
            ret_source,
            lower_bound,
            vtable: e.vtable,
            provenance: &e.provenance,
            doc: e.doc.as_ref(),
        });
    }
    out
}

/// Render the prototype manifest into a consumer format.
pub trait AbiEmitter {
    fn id(&self) -> &'static str;
    fn render(&self, m: &crate::model::AbiManifest) -> String;
}

/// Look up an ABI emitter by its `--format` id.
pub fn abi_by_id(id: &str) -> Option<Box<dyn AbiEmitter>> {
    match id {
        "cssharp" => Some(Box::new(CsSharpAbi)),
        "metamod" => Some(Box::new(MetamodAbi)),
        "modsharp" => Some(Box::new(ModSharpAbi)),
        "swiftly" => Some(Box::new(SwiftlyAbi)),
        "plugify" => Some(Box::new(PlugifyAbi)),
        _ => None,
    }
}

/// Every ABI `--format` id `abi_by_id` accepts (keep in sync with it).
pub const ABI_FORMAT_IDS: &[&str] = &["cssharp", "metamod", "modsharp", "swiftly", "plugify"];

/// What every emitter says about a return nobody declared. One constant so the five outputs cannot drift
/// into describing the same fact differently.
const UNDECLARED_RETURN: &str =
    "RETURN TYPE UNDECLARED — this is the raw return register, not a typed result";

/// What every emitter says about a declaration the callee under-reads. One constant, same reason.
const LOWER_BOUND: &str = "ARGUMENTS ARE A LOWER BOUND — the callee reads fewer registers than this \
     passes, so a trailing argument may be ignored; calling through it is safe, the extra register is \
     simply unread";

/// What every emitter says about where a description came from. One wording per source, for the same
/// reason as the two constants above: five outputs must not describe one fact differently.
///
/// The marker is load-bearing, not decoration. Most of a shipped artifact's prose is this project's own
/// reading of the build rather than Valve's, and the two carry very different weight — an author acting
/// on a sentence has to know which they are reading. An id this does not recognise is printed VERBATIM
/// rather than quietly presented as Valve's.
fn doc_origin(source: &str) -> String {
    match source {
        model::Doc::VALVE => "Valve's own text, read from the registry in the binary".into(),
        "derived" => {
            "source2rosetta's, DERIVED — fixed by the surrounding facts, not Valve's".into()
        }
        "generated" => "source2rosetta's, GENERATED — a reading of this build, not Valve's".into(),
        other => format!("source2rosetta's, source `{other}` — not Valve's"),
    }
}

/// Escape text destined for a C# XML doc comment.
///
/// Not cosmetic: the prototypes this carries are full of characters XML reserves. A `CAI_Concept&`
/// parameter, a `CUtlVector<char*>`, and the `<undeclared>` marker itself all make the comment
/// malformed — the C# compiler reports CS1570 on every one, and a `<undeclared>` reads as an unclosed
/// element rather than as text.
fn xml(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
}

/// The banner every generated SOURCE file carries: what made it, from which build, and the warning that
/// editing it is pointless. The build id is the thing a reader most needs — a prototype is only true of
/// the build it was judged against.
fn source_banner(m: &crate::model::AbiManifest, comment: &str) -> String {
    format!(
        "{c} <auto-generated> by source2rosetta-gen — DO NOT EDIT.\n\
         {c} game: {g}   build: {b}\n\
         {c} Every prototype below was VERIFIED against the register footprint measured in that build.\n\
         {c} Regenerate on every gamedata update; a prototype is only true of the build it was judged against.\n\n",
        c = comment,
        g = m.meta.game_key,
        b = m.meta.source_build
    )
}

/// The head of the C# XML doc block above one generated call site — everything above the caveats.
///
/// **The description takes the `<summary>` and the prototype moves down to a `<remarks>`**, because an
/// editor shows the summary first and "what does this thing do" is what an author hovering a
/// `MemoryFunctionVoid<nint, nint>` is asking. With no description the shape is exactly what it was
/// before descriptions existed: the prototype IS the summary, and the block is one line.
///
/// Shared with ModSharp rather than written twice — the two targets differ in what they generate and
/// not in how a function is documented, and two copies of an XML doc block is how they come to disagree.
fn cs_doc_head(c: &CallShape) -> String {
    match c.doc {
        Some(d) => format!(
            "    /// <summary>{}</summary>\n    \
             /// <remarks><c>{}</c></remarks>\n    \
             /// <remarks>Description: {}.</remarks>",
            xml(&d.text),
            xml(&c.prototype()),
            // Escaped like everything else here: the fixed wordings need none, but an artifact's own
            // `source` id ends up in this line verbatim, and one `<` in it is a malformed comment.
            xml(&doc_origin(&d.source)),
        ),
        None => format!("    /// <summary><c>{}</c></summary>", xml(&c.prototype())),
    }
}

/// The caveat remarks that follow it: what this artifact will not let a call site pretend about itself.
fn cs_doc_caveats(c: &CallShape) -> String {
    let mut s = String::new();
    if c.ret == Ret::Undeclared {
        s.push_str(&format!(
            "\n    /// <remarks>{UNDECLARED_RETURN} (measured class: <c>{}</c>).</remarks>",
            xml(c.ret_source)
        ));
    }
    if c.lower_bound {
        s.push_str(&format!("\n    /// <remarks>{LOWER_BOUND}.</remarks>"));
    }
    s
}

/// CounterStrikeSharp: a C# source file of `MemoryFunction*` fields bound to the gamedata key.
///
/// It has to be SOURCE, not data: the argument list is the generic parameter list of
/// `MemoryFunctionVoid<T1..T10>` / `MemoryFunctionWithReturn<T1..T10, TResult>`, which the C# compiler
/// resolves — no JSON can supply it, and CS#'s own gamedata loader would silently ignore an added key.
/// This is the file `Modules/Memory/VirtualFunctions.cs` maintains by hand, generated instead.
pub struct CsSharpAbi;

/// The generic families stop at ten type parameters, receiver included.
const CS_MAX_ARITY: usize = 10;

impl AbiEmitter for CsSharpAbi {
    fn id(&self) -> &'static str {
        "cssharp"
    }

    fn render(&self, m: &crate::model::AbiManifest) -> String {
        let mut s = source_banner(m, "//");
        s.push_str("using CounterStrikeSharp.API.Modules.Memory;\n\nnamespace CounterStrikeSharp.API.Modules.Memory;\n\n");
        s.push_str("public static class RosettaFunctions\n{\n");
        for c in callable_shapes(m) {
            if c.params.len() > CS_MAX_ARITY || !c.params.iter().all(|t| t.cs_marshalable()) {
                continue;
            }
            let mut generics: Vec<&str> = c.params.iter().map(|t| t.cs()).collect();
            let family = match c.ret {
                Ret::Void => "FunctionVoid",
                Ret::Declared(r) => {
                    if !r.cs_marshalable() {
                        continue;
                    }
                    generics.push(r.cs());
                    "FunctionWithReturn"
                }
                Ret::Undeclared => {
                    generics.push("nint");
                    "FunctionWithReturn"
                }
            };
            // The two binding forms are not interchangeable. A signature resolves to ONE address and
            // can be a static field; a vtable slot is per-object — `VirtualFunction*` takes the
            // instance and reads the slot out of that object's own vtable — so it has to be a factory
            // the caller passes the receiver to. Binding a slot through `GetSignature` would look up a
            // key that is in the gamedata's `offsets` section, never its `signatures` one.
            let binding = match c.vtable {
                // `VirtualFunctionVoid` / `VirtualFunctionWithReturn` start at one argument type,
                // which a slot always has (the receiver). A zero-argument one could only come from a
                // declaration claiming a complete list with nothing in it, and there is no family to
                // render it into.
                Some(_) if c.params.is_empty() => continue,
                Some(slot) => format!(
                    "    public static Virtual{}<{}> {}(nint instance) =>\n        new(instance, {slot});\n\n",
                    family,
                    generics.join(", "),
                    c.ident,
                ),
                None => format!(
                    "    public static readonly Memory{}{} {} =\n        new(GameData.GetSignature(\"{}\"));\n\n",
                    family,
                    // `MemoryFunctionVoid` is the one member of either family with no type parameters
                    // at all, so a nullary void call renders bare rather than as `<>`.
                    if generics.is_empty() {
                        String::new()
                    } else {
                        format!("<{}>", generics.join(", "))
                    },
                    c.ident,
                    c.key,
                ),
            };
            let how = match c.vtable {
                Some(slot) => format!("vtable slot {slot}"),
                None => "signature".to_string(),
            };
            s.push_str(&format!(
                "{}\n    /// <remarks>verified · {how} · {}</remarks>{}\n{}",
                cs_doc_head(&c),
                c.provenance.join(", "),
                cs_doc_caveats(&c),
                binding,
            ));
        }
        s.push_str("}\n");
        s
    }
}

/// Metamod:Source: a C++ header of function-pointer typedefs.
///
/// The only target that takes the DECLARED types verbatim rather than the coarse ABI classes — it is
/// C++, so `void (*)(CBaseEntity*, Vector*)` is exactly what a plugin wants, and collapsing it to
/// `void*` would throw away the very thing this artifact adds. MM:S core has no gamedata subsystem, and
/// its plugins (CS2Fixes, cs2kz-metamod, …) hand-write precisely these typedefs today.
pub struct MetamodAbi;

impl AbiEmitter for MetamodAbi {
    fn id(&self) -> &'static str {
        "metamod"
    }

    fn render(&self, m: &crate::model::AbiManifest) -> String {
        let mut s = source_banner(m, "//");
        s.push_str(
            "#pragma once\n\n\
             // Forward declarations are the consumer's: this header names types it does not define,\n\
             // because the DECLARED type is the information worth shipping.\n\
             //\n\
             // Where a prototype's first parameter is the receiver and no source declared it (Itanium\n\
             // mangling cannot encode `this`), it is spelled from the function's own class and marked\n\
             // `[this]`. It is a real register in the call frame — omitting it would shift every\n\
             // argument by one.\n\
             //\n\
             // A `_vtidx` beside a typedef means the function is reached through the object's vtable,\n\
             // not by scanning for an address: `((T)(*(void***)self)[idx])(self, ...)`.\n\n\
             namespace rosetta {\n\n",
        );
        for c in callable_shapes(m) {
            let (ret, mut caveat) = match c.ret {
                Ret::Undeclared => (
                    "void*".to_string(),
                    format!(
                        "\n// {UNDECLARED_RETURN} (measured class: {}).",
                        c.ret_source
                    ),
                ),
                _ => (c.ret_source.to_string(), String::new()),
            };
            if c.lower_bound {
                caveat.push_str(&format!("\n// {LOWER_BOUND}."));
            }
            // The receiver-inclusive list, with the implied one marked so a reader can tell what the
            // source said from what the deriver settled.
            let args = c
                .declared
                .iter()
                .enumerate()
                .map(|(i, p)| {
                    if i == 0 && c.receiver_implied {
                        format!("{p} /*[this]*/")
                    } else {
                        p.clone()
                    }
                })
                .collect::<Vec<_>>()
                .join(", ");
            // The prose goes ABOVE the identity line, which is where a C++ reader expects a member's
            // documentation and where a `//` comment is safe: the text is one line by construction
            // (`model::one_line`), so nothing after it can fall out of the comment and into the header.
            if let Some(d) = c.doc {
                s.push_str(&format!(
                    "// {}\n// Description: {}.\n",
                    d.text,
                    doc_origin(&d.source)
                ));
            }
            s.push_str(&format!(
                "// {} · verified · {}{}\nusing {}_t = {} (*)({});\n",
                c.key,
                c.provenance.join(", "),
                caveat,
                c.ident,
                ret,
                args,
            ));
            if let Some(slot) = c.vtable {
                s.push_str(&format!(
                    "inline constexpr int {}_vtidx = {slot};\n",
                    c.ident
                ));
            }
            s.push('\n');
        }
        s.push_str("} // namespace rosetta\n");
        s
    }
}

/// ModSharp: a C# interface whose methods carry `[AddressKey]`, which its Roslyn source generator
/// (`Sharp.Generator.Sdk`) turns into signature calls. Source rather than data for the same reason as
/// CounterStrikeSharp — the types are compile-time.
///
/// The two locator forms need two different outputs here, because `SignatureCallGenerator` resolves an
/// `[AddressKey]` through `IGameData.GetAddress(key)` and a vtable slot is not in that map at all —
/// rosetta's own ModSharp gamedata writes it under `VFuncs`, which `GetVFuncIndex` reads. ModSharp's
/// `VirtualCallGenerator` covers the slot case but binds the class through a `[NetClass]` type this has
/// no way to name, so the vtable half is emitted directly, in the same shape that generator produces:
/// resolve the index from gamedata once, then dispatch through the instance's own vtable.
pub struct ModSharpAbi;

impl AbiEmitter for ModSharpAbi {
    fn id(&self) -> &'static str {
        "modsharp"
    }

    fn render(&self, m: &crate::model::AbiManifest) -> String {
        let mut s = source_banner(m, "//");
        s.push_str("using Sharp.Shared;\nusing Sharp.Shared.Attributes;\nusing Sharp.Shared.Calls;\n\nnamespace Sharp.Generated;\n\n");

        let shapes = callable_shapes(m);
        let doc = |c: &CallShape| format!("{}{}\n", cs_doc_head(c), cs_doc_caveats(c));
        let ret_cs = |c: &CallShape| match c.ret {
            Ret::Void => "void",
            Ret::Declared(r) => r.cs(),
            Ret::Undeclared => "nint",
        };

        s.push_str("public unsafe interface IRosettaSignatureCall : ISignatureCall\n{\n");
        for c in shapes.iter().filter(|c| c.vtable.is_none()) {
            let args: Vec<String> = c
                .params
                .iter()
                .enumerate()
                .map(|(i, t)| format!("{} a{i}", t.cs()))
                .collect();
            s.push_str(&format!(
                "{}    [AddressKey(\"{}\")]\n    {} {}({});\n\n",
                doc(c),
                c.key,
                ret_cs(c),
                c.ident,
                args.join(", "),
            ));
        }
        s.push_str("}\n\n");

        // The vtable half. `8 * index` and the double indirection are ModSharp's own generated form
        // (`VirtualCallGenerator`), kept identical so this reads like the rest of a ModSharp build.
        let virt: Vec<&CallShape> = shapes.iter().filter(|c| c.vtable.is_some()).collect();
        s.push_str(
            "/// <summary>Vtable calls. <c>Init</c> resolves every slot from gamedata; call it once, \
             after gamedata has loaded.</summary>\npublic static unsafe class RosettaVirtualCalls\n{\n",
        );
        for c in &virt {
            s.push_str(&format!("    private static int _{};\n", c.ident));
        }
        s.push_str("\n    public static void Init(IGameData gameData)\n    {\n");
        for c in &virt {
            // Resolved from gamedata rather than baked in as the literal slot: the index is a property
            // of the BUILD, and reading it the way every other ModSharp call site does means a gamedata
            // update is picked up without a recompile, and a key that vanished fails at Init instead of
            // dispatching through whatever now occupies the slot.
            s.push_str(&format!(
                "        _{} = gameData.GetVFuncIndex(\"{}\");\n",
                c.ident, c.key
            ));
        }
        s.push_str("    }\n\n");
        for c in &virt {
            // params[0] is the receiver — the gate guarantees a vtable shape has one and that it is a
            // pointer — so it names the instance and every other argument follows it.
            let sig: Vec<String> = c
                .params
                .iter()
                .enumerate()
                .map(|(i, t)| {
                    if i == 0 {
                        "nint instance".to_string()
                    } else {
                        format!("{} a{i}", t.cs_unmanaged())
                    }
                })
                .collect();
            // One spelling for the wrapper's return and the pointer's: they have to agree, and the
            // pointer's is the constrained one.
            let ret_um = match c.ret {
                Ret::Declared(r) => r.cs_unmanaged(),
                _ => ret_cs(c),
            };
            let unmanaged: Vec<&str> = c
                .params
                .iter()
                .map(|t| t.cs_unmanaged())
                .chain(std::iter::once(ret_um))
                .collect();
            let call_args: Vec<String> = c
                .params
                .iter()
                .enumerate()
                .map(|(i, _)| {
                    if i == 0 {
                        "instance".into()
                    } else {
                        format!("a{i}")
                    }
                })
                .collect();
            s.push_str(&format!(
                "{}    public static {} {}({})\n        => ((delegate* unmanaged<{}>)\
                 (*(nint*)(*(nint*) instance + 8 * _{})))({});\n\n",
                doc(c),
                ret_um,
                c.ident,
                sig.join(", "),
                unmanaged.join(", "),
                c.ident,
                call_args.join(", "),
            ));
        }
        s.push_str("}\n");
        s
    }
}

/// Swiftly: a data file of per-function type descriptors. Its call bridge splits a string one character
/// per argument (`src/memory/hooks/vfunctions.cpp`), so a JSON file genuinely drives it — no compile
/// step, which is why this target is data where CounterStrikeSharp's is source.
pub struct SwiftlyAbi;

impl AbiEmitter for SwiftlyAbi {
    fn id(&self) -> &'static str {
        "swiftly"
    }

    fn render(&self, m: &crate::model::AbiManifest) -> String {
        let mut doc = Map::new();
        for c in callable_shapes(m) {
            doc.insert(
                c.key.to_string(),
                json!({
                    // The prose, and the id saying whose it is — the machine-readable form of the
                    // marker the generated-source targets print in words. A consumer rendering this
                    // into its own docs needs to be able to attribute it.
                    "description": c.doc.map(|d| d.text.clone()),
                    "description_source": c.doc.map(|d| d.source.clone()),
                    "args": c.params.iter().map(|t| t.swiftly()).collect::<String>(),
                    "ret": match c.ret {
                        Ret::Void => 'v',
                        Ret::Declared(r) => r.swiftly(),
                        Ret::Undeclared => 'p',
                    }.to_string(),
                    // A consumer that only wants typed results filters on this.
                    "ret_declared": c.ret != Ret::Undeclared,
                    // The declared arity is an upper bound here: the callee reads fewer registers than
                    // this passes. Safe to call; a consumer that wants only exact footprints filters it.
                    "args_lower_bound": c.lower_bound,
                    // Which of Swiftly's two paths this function is reached by. `virtual` is the
                    // `VFunctionHook(library, vtable, offsetName, …)` one — it resolves the class's
                    // vtable by name and indexes it, so `args[0]` is the receiver and `vtable` is the
                    // slot. Without this a consumer cannot tell the two apart from the types alone.
                    "call": if c.vtable.is_some() { "virtual" } else { "address" },
                    "vtable": c.vtable,
                    "prototype": c.prototype(),
                }),
            );
        }
        serde_json::to_string_pretty(&Value::Object(doc)).unwrap_or_default()
    }
}

/// Plugify: a data file keyed the same way, carrying the argument classes as names its polyhook layer
/// resolves at RUNTIME (its detour entry point takes a type vector, not a template), plus the declared
/// prototype for a human.
pub struct PlugifyAbi;

impl AbiEmitter for PlugifyAbi {
    fn id(&self) -> &'static str {
        "plugify"
    }

    fn render(&self, m: &crate::model::AbiManifest) -> String {
        // Spelled as NAMES rather than the enum's integers: an integer silently means something else if
        // the enum is ever reordered, and a name fails loudly instead.
        fn plg(t: AbiType) -> &'static str {
            match t {
                AbiType::Ptr => "pointer",
                AbiType::Str => "string",
                AbiType::Bool => "bool",
                AbiType::I8 => "int8",
                AbiType::U8 => "uint8",
                AbiType::I16 => "int16",
                AbiType::U16 => "uint16",
                AbiType::I32 => "int32",
                AbiType::U32 => "uint32",
                AbiType::I64 => "int64",
                AbiType::U64 => "uint64",
                AbiType::F32 => "float",
                AbiType::F64 => "double",
            }
        }
        let mut fns = Map::new();
        for c in callable_shapes(m) {
            fns.insert(
                c.key.to_string(),
                json!({
                    // As for Swiftly: the text plus whose it is, so a consumer can attribute it.
                    "description": c.doc.map(|d| d.text.clone()),
                    "descriptionSource": c.doc.map(|d| d.source.clone()),
                    "paramTypes": c.params.iter().map(|t| plg(*t)).collect::<Vec<_>>(),
                    "retType": match c.ret {
                        Ret::Void => "void",
                        Ret::Declared(r) => plg(r),
                        Ret::Undeclared => "pointer",
                    },
                    "retDeclared": c.ret != Ret::Undeclared,
                    "argsLowerBound": c.lower_bound,
                    // As for Swiftly: the type vector alone cannot say whether the address is scanned
                    // for or read out of the object's vtable, and polyhook's virtual and detour entry
                    // points are different calls.
                    "call": if c.vtable.is_some() { "virtual" } else { "address" },
                    "vtable": c.vtable,
                    "prototype": c.prototype(),
                }),
            );
        }
        let mut doc = Map::new();
        doc.insert(m.meta.game_key.clone(), Value::Object(fns));
        serde_json::to_string_pretty(&Value::Object(doc)).unwrap_or_default()
    }
}

// ============================ VSCRIPT / BINDINGS emitters ============================

/// An emitter over `bindings-<game>.json`'s VScript section.
///
/// A separate family from the gamedata, schema and ABI emitters because it renders a different INPUT:
/// the script-facing surface, grouped by the class that owns it. Both targets here exist to feed the
/// Dota custom-game ecosystem, which already has a toolchain for exactly this shape.
pub trait BindingsEmitter {
    fn id(&self) -> &'static str;
    /// `schema` supplies the class BASE CHAIN, which the binding registry does not carry. With it, `.d.ts`
    /// emits `interface X extends Y` the way the ecosystem's published types do; without it, every
    /// interface is flat but still correct — an offline artifact has no schema, and that is the only case
    /// where it is absent.
    fn render(&self, vscript: &[VScriptBinding], schema: Option<&Schema>) -> String;
}

/// The base class to declare an `extends` against, but ONLY when we also emit an interface for it.
///
/// A dangling `extends` would make the file unusable on its own, and self-contained is the safer default:
/// an author merging this beside the ecosystem's packages loses nothing, while an author using it alone
/// would otherwise get an unresolved reference. The base graph comes from the typed schema, so it is
/// present exactly when the artifact carries a schema, i.e. when it came from a full build.
fn vs_base<'a>(
    cls: &str,
    schema: Option<&'a Schema>,
    emitted: &BTreeMap<&str, Vec<&VScriptBinding>>,
) -> Option<&'a str> {
    schema?
        .bases
        .get(cls)?
        .first()
        .map(|b| b.name.as_str())
        .filter(|b| emitted.contains_key(b))
}

/// Group the VScript bindings by owning class, dropping the ones that have none.
///
/// `class` is live-only (see `VScriptBinding::class`), so an OFFLINE build renders nothing here. That is
/// the honest outcome rather than a bug: both output formats declare members under their interface, and a
/// flat list of function names is not a thing either consumer can use.
fn vscript_by_class(vscript: &[VScriptBinding]) -> BTreeMap<&str, Vec<&VScriptBinding>> {
    let mut out: BTreeMap<&str, Vec<&VScriptBinding>> = BTreeMap::new();
    for v in vscript {
        if let Some(c) = v.class.as_deref() {
            out.entry(c).or_default().push(v);
        }
    }
    for v in out.values_mut() {
        // By the C++ name as well as the script one: a script name is NOT unique on a class —
        // `CBodyComponent::SetMaterialGroup` is two different implementations, and `ConnectOutput` is
        // three — so sorting on the script name alone leaves their order decided by the order the
        // registry happened to be read in, and the rendered API stops being reproducible.
        v.sort_by(|a, b| (&a.name, &a.cpp).cmp(&(&b.name, &b.cpp)));
    }
    out
}

/// One member per script-facing name, preferring the row Valve documented — see the call site.
fn dedup_by_script_name<'a>(members: &[&'a VScriptBinding]) -> Vec<&'a VScriptBinding> {
    let mut out: Vec<&VScriptBinding> = Vec::with_capacity(members.len());
    for m in members {
        match out.last_mut() {
            Some(prev) if prev.name == m.name => {
                if prev.description.is_empty() && !m.description.is_empty() {
                    *prev = m;
                }
            }
            _ => out.push(m),
        }
    }
    out
}

/// Escape text destined for a `/** … */` doc comment.
///
/// Two hazards, both present in shipped data: a `*/` inside the text ENDS the comment early (3 Dota
/// descriptions contain one), and a newline drops the rest of the sentence out of the `*`-continued
/// block the format expects (82 do).
fn jsdoc(s: &str) -> String {
    one_line(s).replace("*/", "*\\/")
}

/// The script return type as its TypeScript spelling.
///
/// Lua has one number type, so every numeric `ScriptDataType_t` collapses to `number` — the distinction
/// between `int`, `float` and `uint` is real in the binary and meaningless in the target language, and
/// preserving it would produce declarations that do not typecheck against the ecosystem's own types.
fn vs_ts_type(ret: Option<&str>) -> &'static str {
    match ret {
        Some("void") | None => "void",
        Some("bool") => "boolean",
        Some("int") | Some("uint") | Some("float") => "number",
        Some("string") => "string",
        Some("Vector") => "Vector",
        Some("QAngle") => "QAngle",
        Some("handle") | Some("ehandle") => "CBaseEntity",
        Some("table") => "object",
        _ => "any",
    }
}

/// `api.json` — the shape ModDota's `dota-data` publishes and its `TypeScriptDeclarations` renders from.
///
/// Emitting THIS rather than declarations directly is deliberate: their toolchain already turns this
/// shape into typed `.d.ts` packages, so matching it means the ecosystem's existing pipeline produces
/// up-to-date output instead of a parallel artifact competing with it.
///
/// Two fields are honestly absent. Parameter lists, because the registry does not carry them — types
/// appear only inside Valve's prose descriptions, inconsistently, in about a fifth of entries. And
/// `available`, because a dedicated server never maps `libclient`, so this derivation cannot see the
/// client side at all and claiming `both` would be an assertion about something never read.
pub struct VScriptApiJson;

impl BindingsEmitter for VScriptApiJson {
    fn id(&self) -> &'static str {
        "api-json"
    }
    fn render(&self, vscript: &[VScriptBinding], _schema: Option<&Schema>) -> String {
        let mut classes: Vec<Value> = Vec::new();
        for (cls, members) in vscript_by_class(vscript) {
            let ms: Vec<Value> = members
                .iter()
                .map(|m| {
                    let mut o = serde_json::Map::new();
                    o.insert("kind".into(), json!("function"));
                    o.insert("name".into(), json!(m.name));
                    o.insert("available".into(), json!("server"));
                    if !m.description.is_empty() {
                        o.insert("description".into(), json!(m.description));
                    } else if let Some(d) = &m.doc {
                        // Under a key of OUR name, never theirs. `description` is Valve's field in
                        // their shape and their toolchain renders it as Valve's word; putting this
                        // project's reading of the build there would launder it into their published
                        // types as something Valve wrote.
                        o.insert("rosetta_description".into(), json!(d.text));
                        o.insert("rosetta_description_source".into(), json!(d.source));
                    }
                    o.insert("returns".into(), json!([m.ret.clone().unwrap_or_default()]));
                    o.insert("args".into(), json!([]));
                    // Ours and not theirs: the C++ binding this resolves to, which is the key the same
                    // function appears under in `gamedata-<game>.json`.
                    o.insert("cpp".into(), json!(m.cpp));
                    Value::Object(o)
                })
                .collect();
            classes.push(json!({ "kind": "class", "name": cls, "members": ms }));
        }
        serde_json::to_string_pretty(&Value::Array(classes)).unwrap_or_default()
    }
}

/// `.d.ts` — TypeScript declarations in the style the Dota ecosystem's published types use.
///
/// For authors working against the packages as shipped rather than regenerating them. Members are
/// declared on an interface per class, with Valve's own description as the doc comment — which is the
/// point: hovering a function in an editor shows what Valve wrote about it.
///
/// Parameters are declared `...args: any[]` because the registry does not state them. That is deliberately
/// ugly: it is visible in every signature, so nobody mistakes this for a complete declaration, and it
/// still typechecks. Inventing plausible parameter lists would be worse than saying nothing.
pub struct VScriptDts;

impl BindingsEmitter for VScriptDts {
    fn id(&self) -> &'static str {
        "dts"
    }
    fn render(&self, vscript: &[VScriptBinding], schema: Option<&Schema>) -> String {
        let mut s = String::new();
        s.push_str("/** @noSelfInFile */\n");
        s.push_str("// Generated by source2rosetta-gen from rosetta-<game>.json — do not edit.\n");
        s.push_str(
            "// Return types are Valve's own, read from the script VM's registry. Parameter lists are\n\
             // NOT in that registry, so every member takes `...args: any[]`: the arity is unknown, and\n\
             // guessing it would produce declarations that lie rather than declarations that abstain.\n\n",
        );
        let grouped = vscript_by_class(vscript);
        for (cls, members) in &grouped {
            match vs_base(cls, schema, &grouped) {
                Some(base) => s.push_str(&format!("declare interface {cls} extends {base} {{\n")),
                None => s.push_str(&format!("declare interface {cls} {{\n")),
            }
            // One line per SCRIPT-facing name. Where a class registers a name twice — two C++
            // implementations behind one script member — an author still sees one member, so declaring
            // it twice adds a redundant overload and no information. The row carrying Valve's
            // description wins; `api-json` keeps both, because it states the `cpp` that tells them apart.
            for m in dedup_by_script_name(members) {
                // Valve's own description if the registration carried one — that is the point of this
                // format, and it renders bare, the way the ecosystem's published types do. Where Valve
                // documents nothing, this project's own reading fills the gap and SAYS SO: a hover
                // tooltip is exactly where an unmarked sentence would be taken for Valve's word.
                if !m.description.is_empty() {
                    s.push_str(&format!("    /** {} */\n", jsdoc(&m.description)));
                } else if let Some(d) = &m.doc {
                    s.push_str(&format!(
                        "    /** {}\n     *  ({}.) */\n",
                        jsdoc(&d.text),
                        doc_origin(&d.source)
                    ));
                }
                s.push_str(&format!(
                    "    {}(...args: any[]): {};\n",
                    m.name,
                    vs_ts_type(m.ret.as_deref())
                ));
            }
            s.push_str("}\n\n");
        }
        s
    }
}

/// Look up a bindings emitter by its `--format` id.
pub fn bindings_by_id(id: &str) -> Option<Box<dyn BindingsEmitter>> {
    match id {
        "api-json" => Some(Box::new(VScriptApiJson)),
        "dts" => Some(Box::new(VScriptDts)),
        _ => None,
    }
}

/// Every bindings emitter id `bindings_by_id` accepts (keep in sync with it).
///
/// NOT a `--format` list: both of these are rendered by the single `moddota` format, because they are
/// one consumer's two files — their toolchain reads the JSON and their authors read the declarations.
pub const BINDINGS_FORMAT_IDS: &[&str] = &["api-json", "dts"];

/// Every `--format` `gen` accepts, in the order its help lists them.
///
/// A format names WHO the output is for, not which file it is, so one of them can write several files:
/// a framework gets the gamedata its loader resolves through plus the typed call sites that go through
/// it, and `moddota` gets both of the shapes that ecosystem publishes.
pub const FORMAT_IDS: &[&str] = &[
    "cssharp", "metamod", "modsharp", "swiftly", "plugify", "cs-sdk", "netvars", "moddota", "flat",
];

/// Where a format's consumer is known not to run on a game, and the evidence for saying so.
///
/// **A warning rather than a rule.** Each entry is a claim about somebody else's project, read out of
/// their source at one point in time, and third-party projects add games. So `gen` declines by default
/// and takes `--force`: being wrong here costs a flag, never an outcome.
///
/// What earns an entry is a framework that resolves its own binaries through a FIXED game directory,
/// which is a thing its own source states rather than a thing this project infers. Two do:
/// CounterStrikeSharp and Swiftly.
///
/// **ModSharp is deliberately absent**, though it is equally CS2-first in its paths
/// (`../../csgo/steam.inf`): its gamedata carries no game key at all — flat `Addresses` / `VFuncs`,
/// keyed only by platform — so the file rendered here is byte-for-byte the same whatever game the build
/// targets. The two multi-game targets are absent for the opposite reason: Metamod takes Dota 2 as a
/// first-class SDK and Plugify is built per game (`S2SDK_GAME_NAME`), and BOTH key their gamedata by the
/// game directory, which is what `meta.game_key` already carries.
pub const GAME_SUPPORT: &[(&str, &[&str], &str)] = &[
    (
        "cssharp",
        &["csgo"],
        "CounterStrikeSharp resolves its binaries out of `<dir>/csgo/bin/` (src/core/memory.h)",
    ),
    (
        "swiftly",
        &["csgo"],
        "Swiftly initialises against the `csgo` game directory (src/core/entrypoint.cpp)",
    ),
];

/// What is wrong with rendering `format` from a `game_key` artifact, or `None` where nothing is.
///
/// Names the formats that DO cover the game, because the useful thing to tell someone holding a Dota
/// artifact is not that this one is a dead end but which ones are not.
pub fn game_mismatch(format: &str, game_key: &str) -> Option<String> {
    let (_, _, why) = GAME_SUPPORT
        .iter()
        .find(|(f, games, _)| *f == format && !games.contains(&game_key))?;
    let covered: Vec<&str> = FORMAT_IDS
        .iter()
        .copied()
        .filter(|f| {
            !GAME_SUPPORT
                .iter()
                .any(|(id, games, _)| id == f && !games.contains(&game_key))
        })
        .collect();
    Some(format!(
        "`{format}` is for a consumer that does not run on this game. {why}, and this artifact is \
         `{game_key}`.\n  Formats that do cover `{game_key}`: {}",
        covered.join(", ")
    ))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::model::Gamedata;

    fn abi_entry(params: &[&str], complete: bool, ret: &str, int: u8) -> crate::model::AbiEntry {
        crate::model::AbiEntry {
            tier: "core".into(),
            matched_by: "exact".into(),
            status: crate::model::AbiStatus::Verified,
            params: Some(params.iter().map(|s| (*s).to_string()).collect()),
            params_complete: complete.then_some(true),
            ret: Some(ret.into()),
            derived: Some(crate::model::AbiShape {
                int,
                float: 0,
                stack: false,
                ret: ret.into(),
            }),
            ..crate::model::AbiEntry::blank()
        }
    }

    fn manifest(entries: &[(&str, crate::model::AbiEntry)]) -> crate::model::AbiManifest {
        crate::model::AbiManifest {
            meta: crate::model::AbiMeta {
                game_key: "csgo".into(),
                source_build: "t".into(),
                counts: Default::default(),
            },
            functions: entries
                .iter()
                .map(|(k, v)| ((*k).to_string(), v.clone()))
                .collect(),
        }
    }

    #[test]
    fn a_type_the_declaration_does_not_settle_is_not_guessed() {
        // Pointers and references are all one integer register whatever they point at.
        assert_eq!(abi_type_of("CBaseEntity*"), Some(AbiType::Ptr));
        assert_eq!(abi_type_of("Vector const&"), Some(AbiType::Ptr));
        assert_eq!(abi_type_of("char const*"), Some(AbiType::Str));
        // LP64: the target is Linux, so `long` is 64 bits. Mapping it to 32 would truncate.
        assert_eq!(abi_type_of("long"), Some(AbiType::I64));
        assert_eq!(abi_type_of("float"), Some(AbiType::F32));
        // A named enum or a by-value aggregate has a width this artifact does not state. Refusing is
        // the point: a guess here loads the wrong register width on every call.
        assert_eq!(abi_type_of("GearSlot_t"), None);
        assert_eq!(abi_type_of("Vector"), None);
    }

    #[test]
    fn only_verified_entries_become_calls() {
        let mut stale = abi_entry(&["int"], true, "void", 1);
        stale.status = crate::model::AbiStatus::Mismatch;
        let m = manifest(&[
            ("A::Stale", stale),
            ("A::Good", abi_entry(&["int"], true, "void", 1)),
        ]);
        let shapes = callable_shapes(&m);
        assert_eq!(shapes.len(), 1);
        assert_eq!(shapes[0].key, "A::Good");
    }

    #[test]
    fn the_receiver_must_be_settled_by_evidence_not_assumed() {
        // Declaration names the receiver: taken as given.
        let m = manifest(&[("A::M", abi_entry(&["A*", "int"], true, "void", 2))]);
        assert_eq!(callable_shapes(&m)[0].params.len(), 2);

        // Declaration hides `this`, and the MEASUREMENT independently says there is one more integer
        // argument than it lists — the two agreeing is what licenses prepending it.
        let m = manifest(&[("A::M", abi_entry(&["int"], false, "void", 2))]);
        let s = callable_shapes(&m);
        assert_eq!(s[0].params, vec![AbiType::Ptr, AbiType::I32]);

        // Nothing settles it: the measurement agrees with the declaration as written, so whether there
        // is a receiver is unknown. Emitting either way would shift every argument by a register.
        let m = manifest(&[("A::M", abi_entry(&["int"], false, "void", 1))]);
        assert!(callable_shapes(&m).is_empty());
    }

    /// The same entry, located by a live-validated vtable slot instead of a signature.
    fn at_slot(mut e: crate::model::AbiEntry, slot: i64) -> crate::model::AbiEntry {
        e.vtable = Some(slot);
        e
    }

    #[test]
    fn a_vtable_slot_proves_a_receiver_the_measurement_cannot_see() {
        // `CBaseDoor::GetDataDescMap`'s shape: a virtual that returns a constant and so never reads
        // `this`. Backward liveness measures int=0, which agrees with the declaration as written — so
        // the measurement alone says nothing, and the previous gate dropped it.
        let no_slot = abi_entry(&[], false, "void", 0);
        assert!(callable_shapes(&manifest(&[("A::M", no_slot.clone())])).is_empty());

        // The locator settles it: a slot is only reachable through an object.
        let m = manifest(&[("A::M", at_slot(no_slot, 42))]);
        let s = callable_shapes(&m);
        assert_eq!(s[0].params, vec![AbiType::Ptr]);
        assert!(s[0].receiver_implied);
        // Spelled from the owning class, not as `void*` — the key already names what may be passed.
        assert_eq!(s[0].declared, vec!["A*".to_string()]);
    }

    #[test]
    fn a_vtable_entry_whose_declaration_denies_a_pointer_receiver_is_dropped() {
        // A complete declaration whose first argument is an `int` contradicts the locator: a slot is
        // entered through the object. Emitting it would bind the receiver at the wrong width.
        let m = manifest(&[("A::M", at_slot(abi_entry(&["int"], true, "void", 1), 8))]);
        assert!(callable_shapes(&m).is_empty());
    }

    #[test]
    fn the_two_locator_forms_bind_through_different_calls() {
        let slot = manifest(&[("A::M", at_slot(abi_entry(&["A*"], true, "void", 1), 42))]);
        let sig = manifest(&[("A::M", abi_entry(&["A*"], true, "void", 1))]);

        // CounterStrikeSharp: a signature resolves to one address and can be a static field; a slot is
        // per-object, so it is a factory the caller hands the instance to.
        let cs = CsSharpAbi.render(&slot);
        assert!(cs.contains("VirtualFunctionVoid<nint> A_M(nint instance)"));
        assert!(cs.contains("new(instance, 42)"));
        assert!(!cs.contains("GetSignature"));
        assert!(
            CsSharpAbi
                .render(&sig)
                .contains("MemoryFunctionVoid<nint> A_M =")
        );

        // Metamod: the typedef plus the index, because a C++ caller indexes the vtable itself.
        assert!(MetamodAbi.render(&slot).contains("A_M_vtidx = 42"));
        assert!(!MetamodAbi.render(&sig).contains("A_M_vtidx"));

        // ModSharp: `[AddressKey]` resolves through `GetAddress`, which a slot is not in — the vtable
        // half goes to `GetVFuncIndex` and dispatches through the instance.
        let ms = ModSharpAbi.render(&slot);
        assert!(ms.contains("gameData.GetVFuncIndex(\"A::M\")"));
        assert!(!ms.contains("[AddressKey(\"A::M\")]"));
        assert!(ModSharpAbi.render(&sig).contains("[AddressKey(\"A::M\")]"));

        // The data formats say which path applies rather than leaving it to be inferred from types.
        assert!(SwiftlyAbi.render(&slot).contains("\"call\": \"virtual\""));
        assert!(SwiftlyAbi.render(&slot).contains("\"vtable\": 42"));
        assert!(PlugifyAbi.render(&sig).contains("\"call\": \"address\""));
        assert!(PlugifyAbi.render(&sig).contains("\"vtable\": null"));
    }

    #[test]
    fn an_implied_receiver_appears_in_every_type_list_including_the_prose_one() {
        // The C++ target takes the DECLARED types, and the declaration hid `this`. Leaving it out
        // would shift every argument by one register in the one output that is a call frame verbatim.
        let m = manifest(&[("A::M", abi_entry(&["int"], false, "void", 2))]);
        let mm = MetamodAbi.render(&m);
        assert!(
            mm.contains("using A_M_t = void (*)(A* /*[this]*/, int);"),
            "{mm}"
        );
        // …and the human-readable prototype agrees with the machine-readable list, everywhere.
        assert!(SwiftlyAbi.render(&m).contains("A::M(A*, int) -> void"));
        assert!(SwiftlyAbi.render(&m).contains("\"args\": \"pi\""));
    }

    #[test]
    fn generated_csharp_is_valid_csharp_and_valid_xml() {
        // Every character a prototype is full of is one XML reserves: a reference parameter, a
        // template, and the undeclared-return marker itself.
        let m = manifest(&[(
            "A::M",
            abi_entry(
                &["A*", "CUtlVector<char*>&", "char const*"],
                true,
                "ret=int",
                3,
            ),
        )]);
        for out in [CsSharpAbi.render(&m), ModSharpAbi.render(&m)] {
            assert!(out.contains("CUtlVector&lt;char*&gt;&amp;"), "{out}");
            assert!(out.contains("-&gt; &lt;undeclared&gt;"), "{out}");
            // The escaping is confined to the comments — the code still says what it must.
            assert!(!out.contains("&amp;amp;"));
        }
        // `string` is a managed type and cannot appear in a `delegate* unmanaged<…>`; the direct
        // dispatch half substitutes the UTF-8 pointer, exactly as ModSharp's own generator does.
        let slot = manifest(&[(
            "A::M",
            at_slot(abi_entry(&["A*", "char const*"], true, "void", 2), 9),
        )]);
        let ms = ModSharpAbi.render(&slot);
        assert!(
            ms.contains("delegate* unmanaged<nint, sbyte*, void>"),
            "{ms}"
        );
        assert!(ms.contains("A_M(nint instance, sbyte* a1)"), "{ms}");
        // The `[AddressKey]` half keeps `string`: its generator does the conversion for you.
        assert!(
            ModSharpAbi
                .render(&manifest(&[(
                    "A::M",
                    abi_entry(&["A*", "char const*"], true, "void", 2)
                )]))
                .contains("string a1")
        );
    }

    #[test]
    fn cssharp_drops_a_type_its_own_marshaller_cannot_map() {
        // CS#'s `Type -> DataType` table has no `ushort`, so `ToDataType()` returns null and the
        // function is built from an argument list with a hole in it. The other targets have their own
        // vocabularies and keep it.
        let m = manifest(&[(
            "A::M",
            abi_entry(&["A*", "unsigned short"], true, "void", 2),
        )]);
        assert!(!callable_shapes(&m).is_empty());
        assert!(!CsSharpAbi.render(&m).contains("A_M"));
        assert!(MetamodAbi.render(&m).contains("A_M_t"));
    }

    #[test]
    fn an_undeclared_return_is_kept_but_marked_never_typed() {
        let m = manifest(&[("A::M", abi_entry(&["A*"], true, "ret=int", 1))]);
        let s = callable_shapes(&m);
        assert_eq!(s[0].ret, Ret::Undeclared);
        // Every surface says so: prose in the generated source, a flag in the data formats.
        assert!(CsSharpAbi.render(&m).contains(UNDECLARED_RETURN));
        assert!(MetamodAbi.render(&m).contains(UNDECLARED_RETURN));
        assert!(SwiftlyAbi.render(&m).contains("\"ret_declared\": false"));
        assert!(PlugifyAbi.render(&m).contains("\"retDeclared\": false"));
        // ...and it is never spelled as if it were a type.
        assert!(!CsSharpAbi.render(&m).contains("-> ret=int"));
    }

    #[test]
    fn a_lower_bound_declaration_is_emitted_and_marked_everywhere() {
        // The callee reads fewer registers than the declaration passes — safe to call, since the extra
        // register is simply unread, and the same reasoning this artifact already applies to an
        // undeclared return. But it is NOT an exact footprint, so nothing may present it as one.
        let mut lb = abi_entry(&["A*", "int"], true, "void", 1);
        lb.status = crate::model::AbiStatus::LowerBound;
        let m = manifest(&[("A::M", lb)]);
        let s = callable_shapes(&m);
        assert_eq!(s.len(), 1);
        assert!(s[0].lower_bound);
        assert!(CsSharpAbi.render(&m).contains(LOWER_BOUND));
        assert!(MetamodAbi.render(&m).contains(LOWER_BOUND));
        assert!(ModSharpAbi.render(&m).contains(LOWER_BOUND));
        assert!(SwiftlyAbi.render(&m).contains("\"args_lower_bound\": true"));
        assert!(PlugifyAbi.render(&m).contains("\"argsLowerBound\": true"));
        // …and a genuine mismatch is still refused.
        let mut bad = abi_entry(&["A*", "int"], true, "void", 1);
        bad.status = crate::model::AbiStatus::Mismatch;
        assert!(callable_shapes(&manifest(&[("A::M", bad)])).is_empty());
        // An exactly-matching one is not marked.
        let ok = manifest(&[("A::M", abi_entry(&["A*", "int"], true, "void", 2))]);
        assert!(!callable_shapes(&ok)[0].lower_bound);
        assert!(
            !SwiftlyAbi
                .render(&ok)
                .contains("\"args_lower_bound\": true")
        );
    }

    #[test]
    fn a_description_reaches_every_call_site_format_and_says_whose_it_is() {
        let doc = |text: &str, source: &str| crate::model::Doc {
            text: text.into(),
            source: source.into(),
        };
        let mut e = abi_entry(&["A*", "int"], true, "void", 2);
        e.doc = Some(doc("Kills the entity.", "generated"));
        let m = manifest(&[("A::M", e)]);

        for out in [CsSharpAbi.render(&m), ModSharpAbi.render(&m)] {
            // The prose takes the summary — an editor shows that first, and it is what an author
            // hovering a generic type list is asking for — and the prototype keeps a home of its own.
            assert!(
                out.contains("<summary>Kills the entity.</summary>"),
                "{out}"
            );
            assert!(
                out.contains("<remarks><c>A::M(A*, int) -&gt; void</c></remarks>"),
                "{out}"
            );
            assert!(out.contains("GENERATED — a reading of this build, not Valve's"));
        }
        let mm = MetamodAbi.render(&m);
        assert!(mm.contains("// Kills the entity.\n// Description: source2rosetta's, GENERATED"));
        assert!(
            SwiftlyAbi
                .render(&m)
                .contains("\"description_source\": \"generated\"")
        );
        assert!(
            PlugifyAbi
                .render(&m)
                .contains("\"descriptionSource\": \"generated\"")
        );

        // Valve's own text is attributed to Valve, in the same place, by the same one wording.
        let mut e = abi_entry(&["A*", "int"], true, "void", 2);
        e.doc = Some(doc("Valve wrote this.", crate::model::Doc::VALVE));
        let valve = CsSharpAbi.render(&manifest(&[("A::M", e)]));
        assert!(valve.contains("<summary>Valve wrote this.</summary>"));
        assert!(
            valve.contains("Description: Valve's own text, read from the registry in the binary.")
        );

        // An id nothing here knows is printed verbatim rather than passed off as Valve's — the same
        // rule `flags_raw` follows for a bit whose meaning this build cannot state.
        let mut e = abi_entry(&["A*", "int"], true, "void", 2);
        e.doc = Some(doc("t", "handwritten"));
        let odd = CsSharpAbi.render(&manifest(&[("A::M", e)]));
        assert!(odd.contains("source `handwritten` — not Valve's"), "{odd}");

        // And with nothing to say, the block is exactly what it was before descriptions existed.
        let bare = CsSharpAbi.render(&manifest(&[("A::M", abi_entry(&["A*"], true, "void", 1))]));
        assert!(bare.contains("<summary><c>A::M(A*) -&gt; void</c></summary>"));
        assert!(!bare.contains("Description:"));
    }

    #[test]
    fn a_description_cannot_break_out_of_the_comment_it_is_printed_in() {
        // Real shipped text carries all three hazards: XML metacharacters (~100 descriptions per
        // game), a newline (78), and `*/` (3 in Dota).
        let mut e = abi_entry(&["A*"], true, "void", 1);
        e.doc = Some(crate::model::Doc {
            text: crate::model::one_line("a <b> & c\nsecond line */ end"),
            source: "generated".into(),
        });
        let m = manifest(&[("A::M", e)]);
        for out in [CsSharpAbi.render(&m), ModSharpAbi.render(&m)] {
            assert!(
                out.contains("a &lt;b&gt; &amp; c second line */ end"),
                "{out}"
            );
        }
        // A `//` comment ends at a newline, so every prose line in the C++ header must be one line.
        for line in MetamodAbi.render(&m).lines() {
            assert!(
                line.starts_with("//") || !line.contains("second line"),
                "{line}"
            );
        }
    }

    #[test]
    fn the_script_api_fills_a_gap_valve_left_and_never_overwrites_one() {
        let member = |name: &str, valve: &str, doc: Option<&str>| VScriptBinding {
            name: name.into(),
            class: Some("CBaseEntity".into()),
            cpp: format!("Script_{name}"),
            library: "server".into(),
            description: valve.into(),
            ret: Some("void".into()),
            ret_raw: 0,
            addr: None,
            vtable_slot: None,
            doc: doc.map(|t| crate::model::Doc {
                text: t.into(),
                source: "generated".into(),
            }),
        };
        let vs = vec![
            member("Kill", "Valve's own.", Some("ours, and outranked")),
            member("Quiet", "", Some("Ours: it does a thing. */")),
            member("Silent", "", None),
        ];

        let dts = VScriptDts.render(&vs, None);
        assert!(dts.contains("/** Valve's own. */"), "{dts}");
        assert!(!dts.contains("outranked"), "{dts}");
        // Ours is marked where it lands, because a hover tooltip is exactly where an unattributed
        // sentence gets taken for Valve's word. And `*/` inside it must not end the comment.
        assert!(
            dts.contains("/** Ours: it does a thing. *\\/\n     *  (source2rosetta's, GENERATED")
        );
        // A member nothing describes gets no comment at all — an empty one would read as "documented,
        // and it says nothing". (Counting the indented form: the file's own `@noSelfInFile` banner is
        // a doc comment too.)
        assert!(dts.contains("    Silent(...args: any[]): void;"));
        assert_eq!(dts.matches("    /**").count(), 2);

        // `api.json` is ModDota's shape: `description` is Valve's field, so ours goes under a key of
        // our own name rather than being laundered into their published types as Valve's word.
        let api: Value = serde_json::from_str(&VScriptApiJson.render(&vs, None)).unwrap();
        let ms = &api[0]["members"];
        assert_eq!(ms[0]["description"], "Valve's own.");
        assert!(ms[0].get("rosetta_description").is_none());
        assert!(ms[1].get("description").is_none());
        assert_eq!(ms[1]["rosetta_description"], "Ours: it does a thing. */");
        assert_eq!(ms[1]["rosetta_description_source"], "generated");
        assert!(ms[2].get("rosetta_description").is_none());
    }

    #[test]
    fn a_declared_return_that_cannot_be_represented_drops_the_function() {
        // `Vector` by value is a real declaration this cannot express — dropping it is not the same as
        // treating it as unknown, which would downgrade information the source actually had.
        let m = manifest(&[("A::M", abi_entry(&["A*"], true, "Vector", 1))]);
        assert!(callable_shapes(&m).is_empty());
    }

    fn sample() -> Gamedata {
        let mut gd = Gamedata::default();
        gd.set_signature("Host_Say", "server", "55 48 89 ? E5");
        gd.set_offset("GameEntitySystem", 80);
        gd
    }

    #[test]
    fn cssharp_shape_is_sorted_and_correct() {
        let out = CsSharp.render(&sample());
        // key-sorted: GameEntitySystem before Host_Say; offsets/signatures shapes intact.
        let g = out.find("GameEntitySystem").unwrap();
        let h = out.find("Host_Say").unwrap();
        assert!(g < h, "entries must be key-sorted");
        assert!(out.contains("\"offsets\""));
        assert!(out.contains("\"library\": \"server\""));
        assert!(out.contains("\"linux\": \"55 48 89 ? E5\""));
    }

    #[test]
    fn locator_value_and_entry_from_value_round_trip() {
        use crate::model::{Entry, Sig};
        // the canonical write/read pair is a true inverse for both locator kinds
        let sig = Entry {
            signature: Some(Sig {
                library: "engine2".into(),
                linux: "55 48 ? E5".into(),
            }),
            offset: None,
            class: None,
            anchors: Vec::new(),
        };
        let off = Entry {
            signature: None,
            offset: Some(158),
            class: None,
            anchors: Vec::new(),
        };
        assert_eq!(entry_from_value(&locator_value(&sig)), sig);
        assert_eq!(entry_from_value(&locator_value(&off)), off);
        // a signature missing its library reads back as "server" — the reader's tolerance
        let v = json!({ "signatures": { "linux": "90" } });
        assert_eq!(entry_from_value(&v).signature.unwrap().library, "server");
    }

    #[test]
    fn metamod_escapes_bytes_and_wildcards() {
        let out = Metamod.render(&sample());
        assert!(out.contains("\"Games\""));
        assert!(out.contains(r"\x55\x48\x89\x2A\xE5")); // ? -> \x2A
        assert!(out.contains("\"linux\"\t\"80\""));
    }

    #[test]
    fn modsharp_splits_addresses_and_vfuncs() {
        let v: serde_json::Value = serde_json::from_str(&ModSharp.render(&sample())).unwrap();
        assert_eq!(v["Addresses"]["Host_Say"]["library"], "server");
        assert_eq!(v["Addresses"]["Host_Say"]["linux"], "55 48 89 ? E5");
        assert_eq!(v["VFuncs"]["GameEntitySystem"]["linux"], 80);
        assert!(v["Addresses"].get("GameEntitySystem").is_none()); // offset isn't an address
    }

    #[test]
    fn swiftly_signatures_use_lib_key() {
        let v: serde_json::Value = serde_json::from_str(&Swiftly.render(&sample())).unwrap();
        assert_eq!(v["Host_Say"]["lib"], "server");
        assert_eq!(v["Host_Say"]["linux"], "55 48 89 ? E5");
        assert!(v.get("GameEntitySystem").is_none()); // offsets are a separate file
    }

    #[test]
    fn plugify_is_game_keyed_with_linuxsteamrt64() {
        let v: serde_json::Value = serde_json::from_str(&Plugify.render(&sample())).unwrap();
        assert_eq!(v["csgo"]["Signatures"]["Host_Say"]["library"], "server");
        assert_eq!(
            v["csgo"]["Signatures"]["Host_Say"]["linuxsteamrt64"],
            "55 48 89 ? E5"
        );
        assert_eq!(
            v["csgo"]["Offsets"]["GameEntitySystem"]["linuxsteamrt64"],
            80
        );
    }

    #[test]
    fn game_keyed_emitters_honor_a_non_csgo_game_key() {
        // The game-keyed formats (Metamod, Plugify) must wrap output in the model's game_key, not a
        // hardcoded "csgo" — the multi-game seam. A dota-keyed model renders under "dota".
        let mut gd = sample();
        gd.game_key = "dota".to_string();
        let mm = Metamod.render(&gd);
        assert!(
            mm.contains("\t\"dota\"\n") && !mm.contains("\"csgo\""),
            "metamod: {mm}"
        );
        let pl: Value = serde_json::from_str(&Plugify.render(&gd)).unwrap();
        assert_eq!(
            pl["dota"]["Offsets"]["GameEntitySystem"]["linuxsteamrt64"],
            80
        );
        assert!(pl.get("csgo").is_none());
    }

    #[test]
    fn every_format_id_resolves_and_renders() {
        for id in GAMEDATA_FORMAT_IDS {
            let em = by_id(id).unwrap_or_else(|| panic!("by_id({id}) is None"));
            assert!(!em.render(&sample()).is_empty(), "{id} rendered empty");
        }
        // Every `--format` has to be reachable through one of the four emitter families, or `gen`
        // advertises an id it then rejects. `moddota` is the one that maps to a family rather than to
        // an emitter: it is a CONSUMER with two files, which is why the CLI list is not a registry.
        for id in FORMAT_IDS {
            let known = by_id(id).is_some()
                || schema_by_id(id).is_some()
                || abi_by_id(id).is_some()
                || *id == "moddota"
                || *id == "flat";
            assert!(known, "--format {id} resolves to no emitter");
        }
        for id in BINDINGS_FORMAT_IDS {
            assert!(bindings_by_id(id).is_some(), "bindings_by_id({id}) is None");
        }
    }

    #[test]
    fn a_consumer_that_does_not_run_on_this_game_is_named_and_so_are_the_ones_that_do() {
        // CounterStrikeSharp resolves its own binaries out of `<dir>/csgo/bin/`, so Dota locators in
        // its shape describe a file that can never load. Saying which formats DO cover the game is the
        // useful half: someone holding a Dota artifact needs a way forward, not just a closed door.
        let m = game_mismatch("cssharp", "dota").expect("cssharp does not run on dota");
        assert!(m.contains("CounterStrikeSharp"), "{m}");
        assert!(
            m.contains("metamod") && m.contains("moddota") && m.contains("plugify"),
            "{m}"
        );
        assert!(
            !m.contains("swiftly"),
            "swiftly does not cover dota either: {m}"
        );
        assert!(game_mismatch("cssharp", "csgo").is_none());

        // Multi-game by evidence: Metamod takes Dota 2 as a first-class SDK, Plugify is built per game,
        // and both key their gamedata by the game directory — which is what `game_key` already carries.
        for f in ["metamod", "plugify", "moddota", "flat", "cs-sdk", "netvars"] {
            assert!(game_mismatch(f, "dota").is_none(), "{f} should cover dota");
        }
        // ModSharp is CS2-FIRST but not CS2-only in the shape rendered here: its gamedata carries no
        // game key at all, so the file is the same whatever game the build targets.
        assert!(game_mismatch("modsharp", "dota").is_none());

        // Every id the table constrains has to be a format that exists, or the constraint silently
        // applies to nothing.
        for (id, _, _) in GAME_SUPPORT {
            assert!(
                FORMAT_IDS.contains(id),
                "GAME_SUPPORT names unknown format {id}"
            );
        }
    }

    #[test]
    fn metamod_handles_all_wildcard_token_forms() {
        // sig::Pattern accepts ?, ?? and * as wildcards — all must render as the VDF wildcard \x2A,
        // never as a corrupt \x?? / \x* escape.
        let mut gd = Gamedata::default();
        gd.set_signature("F", "server", "48 ?? 89 * E5 ?");
        let out = Metamod.render(&gd);
        assert!(out.contains(r"\x48\x2A\x89\x2A\xE5\x2A"), "got: {out}");
        assert!(!out.contains(r"\x??") && !out.contains(r"\x*"));
    }

    fn sample_schema() -> Schema {
        use crate::model::{Field, SchemaMeta};
        use std::collections::BTreeMap;
        let f = |offset, ty: &str| Field {
            offset,
            ty: ty.into(),
            kind: crate::model::FieldKind::Ref,
            size: 4,
            name_hash: 0,
        };
        let mut base = BTreeMap::new();
        base.insert("m_iHealth".to_string(), f(0x5B0, "int32"));
        let mut nested = BTreeMap::new();
        nested.insert("m_x".to_string(), f(0, "float32"));
        let mut classes = BTreeMap::new();
        classes.insert("CBaseEntity".to_string(), base);
        classes.insert("Outer_t::Inner_t".to_string(), nested); // must sanitize to a valid C# identifier
        Schema {
            meta: SchemaMeta {
                game_key: "csgo".into(),
                source_build: "b".into(),
                typed: 2,
                untyped: 0,
                enums: 0,
                types: 0,
            },
            classes,
            bases: BTreeMap::new(),
            enums: BTreeMap::new(),
            types: BTreeMap::new(),
        }
    }

    #[test]
    fn every_schema_format_renders_nonempty() {
        let s = sample_schema();
        for id in SCHEMA_FORMAT_IDS {
            let em = schema_by_id(id).unwrap_or_else(|| panic!("schema_by_id({id}) is None"));
            assert!(!em.render(&s).is_empty(), "{id} rendered empty");
        }
    }

    #[test]
    fn cs_sdk_sanitizes_idents_and_maps_primitives() {
        let cs = CsSdk.render(&sample_schema());
        // primitive types map to C# built-ins; the offset is hex
        assert!(
            cs.contains("public const int m_iHealth = 0x5B0; // int"),
            "{cs}"
        );
        // a `::` nested class name becomes a valid C# identifier, original kept in the doc comment
        assert!(cs.contains("public static class Outer_t__Inner_t"), "{cs}");
        assert!(cs.contains("<c>Outer_t::Inner_t</c>"), "{cs}");
        // no raw `::` ever leaks into an emitted identifier
        for line in cs.lines().filter(|l| l.starts_with("public static class ")) {
            assert!(!line.contains("::"), "invalid C# class ident: {line}");
        }
    }

    #[test]
    fn a_call_identifier_is_valid_in_every_target_and_never_collides() {
        // The ordinary case is untouched.
        assert_eq!(
            call_ident("CBaseEntity::SetAbsOrigin"),
            "CBaseEntity_SetAbsOrigin"
        );
        // The two defects this replaced. `operator=` used to emit a raw `=` into a C# field name and a
        // C++ typedef — a syntax error that SHIPPED in both.
        let op = call_ident("CEconItemView::operator=");
        assert!(valid_ident(&op), "{op}");
        // …and the console-command on/off pair used to collapse to one identifier, i.e. a duplicate
        // definition. Distinct keys must stay distinct.
        let plus = call_ident("ConCommand::+bugvoice");
        let minus = call_ident("ConCommand::-bugvoice");
        assert!(
            valid_ident(&plus) && valid_ident(&minus),
            "{plus} / {minus}"
        );
        assert_ne!(plus, minus);
        // Everything a Valve name actually throws at it stays valid: templates, thunks, arrows.
        for k in [
            "Attribute::Detail::GetterT<CAnimMotorInstance, CUtlString const&>::Get",
            "IGameSystem::InitAllSystems->pFirst",
            "CFoo::~CFoo",
            "CFoo::operator[]",
            "CFoo::operator*",
        ] {
            let id = call_ident(k);
            assert!(valid_ident(&id), "{k} -> {id}");
        }
        // A leading digit is not a legal identifier in either language.
        assert!(valid_ident(&call_ident("2Fast")));
    }

    /// Valid in C# and C++ alike: ASCII alphanumerics and `_`, not starting with a digit.
    fn valid_ident(s: &str) -> bool {
        !s.is_empty()
            && !s.starts_with(|c: char| c.is_ascii_digit())
            && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
    }
}
