//! The canonical derived-gamedata model — the single in-memory representation the derivation produces //! and the emitters consume. Deliberately format-agnostic (no serde_json shapes here): `render::*` //! turns it into CSSharp JSON, Metamod VDF, etc. Keeping the shape here (not smeared through `json!` //! call sites) is what lets one derivation feed every output format and a standalone generator. use std::collections::BTreeMap; /// A byte-pattern signature located in a specific library. #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct Sig { pub library: String, // "server", "engine2", … (the module the pattern scans) pub linux: String, // space-hex pattern with `?` wildcards, e.g. "55 48 89 ? E5" } /// One gamedata function: a vtable-method offset, a scan signature, or (rarely) both — plus, where we /// have them, string ANCHORS that locate the same function a different way. #[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct Entry { #[serde(default, skip_serializing_if = "Option::is_none")] pub signature: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub offset: Option, // vtable slot index (or a carried member offset) /// For a vtable-OFFSET locator: the class whose vtable the slot was measured on. /// /// Part of the locator, not decoration — a slot index alone locates nothing, since it is only meaningful /// relative to a particular class's vtable. Taken from the class whose vtable the derivation actually /// chained the offset through, never parsed out of the entry name: a method declared on a base class /// routinely sits in a derived class's vtable, so the name's class and the measured class are different /// facts and only the second one locates anything. #[serde(default, skip_serializing_if = "Option::is_none")] pub class: Option, /// Distinctive string literals this function references, each unique to it within its library. /// /// NOT a third locator competing with the sig-XOR-offset pair — a supplement with a DIFFERENT failure /// mode. A byte signature is a snapshot of one build's codegen; a string survives a recompile that /// moves instructions. So a consumer that can resolve anchors (ModSharp's `refs.strings`) has a /// locator that keeps working across the window between Valve shipping a build and us republishing, /// which is exactly when a byte pattern is most likely to have drifted. /// /// Emitted alongside the signature, never instead of it: the two are independent, and a consumer /// choosing between them is better served by having both than by our picking one. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub anchors: Vec, } impl Entry { /// A signature-only locator (the deriver's sig-XOR-offset invariant as a constructor). pub fn signature(library: impl Into, linux: impl Into) -> Entry { Entry { signature: Some(Sig { library: library.into(), linux: linux.into(), }), offset: None, class: None, anchors: Vec::new(), } } /// A vtable-offset-only locator. pub fn offset(linux: i64) -> Entry { Entry { signature: None, offset: Some(linux), class: None, anchors: Vec::new(), } } } /// The default output game-key when none is set — CS2's Steam content-dir token. Games-keyed emitters /// (Metamod/Plugify) fall back to this so an older model JSON (no `game_key`) still renders as CS2. fn default_game_key() -> String { "csgo".to_string() } /// The derived gamedata for one build: function name -> entry. BTreeMap so iteration/output is /// deterministically key-sorted, matching serde_json's Map key ordering byte-for-byte. /// /// Serializing this IS the canonical "model JSON" — the single per-build artifact the deriver /// publishes and the standalone `source2rosetta-gen` reads back to produce every framework's format. #[derive(Debug, serde::Serialize, serde::Deserialize)] pub struct Gamedata { pub entries: BTreeMap, /// The game token the game-keyed emitters wrap output in (Metamod `Games { {..} }`, /// Plugify `{ "": {..} }`). Persisted here because `source2rosetta-gen` renders from the model /// JSON alone, with no access to the deriver's `GameProfile`. Defaults to CS2's `csgo`. #[serde(default = "default_game_key")] pub game_key: String, } impl Default for Gamedata { fn default() -> Self { Self { entries: BTreeMap::new(), game_key: default_game_key(), } } } /// Why the derivation could not produce a shipped locator — the closed domain the `unresolved` tier reports. #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] #[serde(rename_all = "kebab-case")] pub enum FlagReason { /// A catalogued signature that no longer resolves uniquely / wasn't recovered in the target. SigDrifted, /// A vtable offset whose recency-weighted vote fell below the confidence bar. OffsetLowConf, /// No chainable anchor / no reference history at all. Unresolved, /// The sig SHIPPED, but its ABI prototype shape drifted from the model consensus (review the prototype). AbiDrift, /// A signature DID resolve, and the address it resolved to is provably a different function — the /// binary names it something else and the code operates on another class. Distinct from `SigDrifted` /// because it calls for the opposite response: a drifted entry may simply reappear next build, while /// this one says the catalogue's own signature is finding a decoy and the entry needs a new locator. NameContradicted, } impl FlagReason { /// The kebab id — the same string the `kebab-case` serialization emits, for callers that carry the /// reason across to the monolith's `Unresolved.reason` (a `String`, kept stable for byte-reproducibility). pub fn as_str(self) -> &'static str { match self { FlagReason::SigDrifted => "sig-drifted", FlagReason::OffsetLowConf => "offset-low-conf", FlagReason::Unresolved => "unresolved", FlagReason::AbiDrift => "abi-drift", FlagReason::NameContradicted => "name-contradicted", } } } /// A catalogue entry the derivation could NOT confidently produce. Emitted as a first-class sidecar /// (never guessed into the gamedata — safety > recall) so it is both reviewable and machine-readable. #[derive(Debug, Clone, serde::Serialize)] pub struct Flagged { pub name: String, pub reason: FlagReason, /// The signal we do have: the carried value, vote confidence, "no reference", … pub detail: String, } impl Flagged { pub fn new(name: impl Into, reason: FlagReason, detail: impl Into) -> Self { Self { name: name.into(), reason, detail: detail.into(), } } } impl Gamedata { pub fn set_signature( &mut self, name: impl Into, library: impl Into, linux: impl Into, ) { self.entries.entry(name.into()).or_default().signature = Some(Sig { library: library.into(), linux: linux.into(), }); } pub fn set_offset(&mut self, name: impl Into, linux: i64) { self.entries.entry(name.into()).or_default().offset = Some(linux); } /// Record the class a vtable-offset locator is relative to. pub fn set_class(&mut self, name: impl Into, class: impl Into) { self.entries.entry(name.into()).or_default().class = Some(class.into()); } /// Attach string anchors to `name`, creating the entry if the derivation reached it by no other route. /// /// Deduplicated and order-preserving: the catalogue can carry the same anchor twice across variants, /// and the emitted list is part of a byte-reproducible artifact, so it must not depend on how many /// times a source repeated itself. pub fn add_anchors>( &mut self, name: impl Into, anchors: impl IntoIterator, ) { let e = self.entries.entry(name.into()).or_default(); for a in anchors { let a = a.into(); if !a.is_empty() && !e.anchors.contains(&a) { e.anchors.push(a); } } } pub fn len(&self) -> usize { self.entries.len() } pub fn is_empty(&self) -> bool { self.entries.is_empty() } } // =========================================================================================== // The tiered catalogue — WHERE each named function is. Four confidence tiers with provenance // + live-validation folded inline. It is the locator half of what `merge` folds into the shipped // `rosetta-.json`, where each entry becomes one [`FunctionRecord`]. // A `MonoEntry` EMBEDS `Entry`, so the locator shape stays // single-sourced on `render::locator_value` and never diverges. Lib-agnostic: an entry carries its // `library` in the signature locator, so the monolith spans every derived library, not just libserver. // =========================================================================================== /// A monolith entry's confidence tier — its finer label within a section, serialized as kebab strings /// (`core`, `valve-table`, `self-named`, `dict-exact`, `contextual`, `corroborated`, `high`, `medium`, /// `low`). Declared most-confident first. #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "kebab-case")] pub enum Tier { Core, /// Named by a table Valve compiled into the binary being derived — today the entity-IO datadesc, /// whose records pair a handler name with the handler itself. The only naming source that is neither /// inferred nor transferred from another build, so it outranks every tier below it: the binary is /// vouching for its own function names. (It is still not [`Tier::Core`], which additionally means /// cross-build history — fingerprint verification and drift detection — that one build cannot supply. /// The Pulse binding table names far more functions but does not LOCATE them; it ships as its own /// registry, see [`Bindings`].) ValveTable, SelfNamed, /// Dictionary-corroborated by the FOLD (an exact hit in the harvested name catalogue). DictExact, Contextual, /// Dictionary-corroborated by the EXPERIMENTAL band, and also the label the macOS ground-truth /// transfer carries. Kept distinct from [`Tier::DictExact`] rather than merged: the two are produced /// by different paths, and folding them together would additionally conflate ground-truth symbols /// with dictionary guesses. Anything counting "corroborated" for display must count BOTH — see /// [`Tier::is_dict_corroborated`]. Corroborated, High, Medium, Low, } impl Tier { /// Is this tier a dictionary/ground-truth corroboration, under either of its two labels? pub fn is_dict_corroborated(self) -> bool { matches!(self, Tier::DictExact | Tier::Corroborated) } } /// Per-entry provenance, folded inline. Optional by tier: a `core` entry carries almost nothing, an /// `experimental` guess carries the full grading. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct Provenance { pub tier: Tier, /// Raw address in THIS build — a debugging/trace anchor (the monolith is per-build, so it's coherent). #[serde(default, skip_serializing_if = "Option::is_none")] pub addr: Option, /// The confidence LABEL as the harvest records it — `"high"` / `"medium"` / `"low"` — a separate axis /// from `tier` (a self-named entry can still be low-confidence). A string, not a number. #[serde(default, skip_serializing_if = "Option::is_none")] pub confidence: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub self_named: Option, /// Return is struct-by-value → unsafe to naive-call (the `RetClass::ByValue` flag). #[serde(default, skip_serializing_if = "Option::is_none")] pub by_value: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub ret_class: Option, /// `"catalogue"` | `"source2rosetta-nameext"` | `"contribution:"` | … #[serde(default, skip_serializing_if = "Option::is_none")] pub source: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub rtti_class: Option, /// high_confidence tier only — the naming rationale. #[serde(default, skip_serializing_if = "Option::is_none")] pub rationale: Option, /// experimental tier only — how the dictionary corroborated the guess (`"exact"` / `"bare"` / `"none"`). #[serde(default, skip_serializing_if = "Option::is_none")] pub corroboration: Option, /// experimental tier only — the same name was guessed at more than one address. #[serde(default, skip_serializing_if = "Option::is_none")] pub collision: Option, /// experimental tier only — protobuf/serializer/foreign plumbing (flagged, kept for completeness). #[serde(default, skip_serializing_if = "Option::is_none")] pub dead_weight: Option, /// core only — the target's ABI prototype-shape differs from the model's consensus (`"target [..] vs /// history [..]"`). The signature still ships (a drifted arg-list is a loader-hook seam a byte-sig can't /// see, not a wrong locator), but a consumer that ptrace-calls it should re-check the prototype. #[serde(default, skip_serializing_if = "Option::is_none")] pub abi_drift: Option, } impl Tier { /// The tier's kebab id — the same string the `kebab-case` serialization emits, for a deriver that needs /// it as a plain `&str` (e.g. a count-by-tier tally) without going through serde. pub fn as_str(self) -> &'static str { match self { Tier::Core => "core", Tier::ValveTable => "valve-table", Tier::SelfNamed => "self-named", Tier::DictExact => "dict-exact", Tier::Contextual => "contextual", Tier::Corroborated => "corroborated", Tier::High => "high", Tier::Medium => "medium", Tier::Low => "low", } } /// Parse a tier from its kebab id (the inverse of the `kebab-case` serialization) — so a deriver holding /// a tier as a computed string can lift it to the typed enum without a serde_json round-trip. `None` for /// an unrecognised id (the caller decides whether that is a hard error). pub fn from_id(s: &str) -> Option { Some(match s { "core" => Tier::Core, "valve-table" => Tier::ValveTable, "self-named" => Tier::SelfNamed, "dict-exact" => Tier::DictExact, "contextual" => Tier::Contextual, "corroborated" => Tier::Corroborated, "high" => Tier::High, "medium" => Tier::Medium, "low" => Tier::Low, _ => return None, }) } } impl Provenance { /// A provenance with only its tier set (all optional fields `None`) — the base for `core` entries and /// the start point for functional-update construction (`Provenance { source: …, ..with_tier(t) }`). pub fn with_tier(tier: Tier) -> Self { Self { tier, addr: None, confidence: None, self_named: None, by_value: None, ret_class: None, source: None, rtti_class: None, rationale: None, corroboration: None, collision: None, dead_weight: None, abi_drift: None, } } } /// One monolith function: a locator (the `signature`/`offset` model shape, flattened in from [`Entry`] so /// the shape is single-sourced) plus its provenance and live-validation verdict. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct MonoEntry { #[serde(flatten)] pub locator: Entry, /// The argument footprint read out of THIS build's machine code — see [`AbiShape`]. Absent when the /// function's address wasn't resolvable offline. #[serde(default, skip_serializing_if = "Option::is_none")] pub abi: Option, pub provenance: Provenance, /// Live-validation verdict: `Some(true)` passed, `Some(false)` dropped confident-bad, `None` unvalidated. #[serde(default)] pub validated: Option, /// The OTHER shipped names that locate this same function — key-sorted, never including this entry's own /// name, and empty for the ~95% of entries that are the only name for their target. /// /// Several names on one function is normal and not a defect: the catalogue is assembled from independent /// sources that spell the same function differently (`CreateEntityByName`, `UTIL::CreateEntityByName` /// and `CGameEntitySystem::CreateEntityByName` are one address), and dropping all but one would discard /// whichever spelling a given consumer's existing code already uses. What was missing is that the /// artifact never SAID so, which left a reader unable to tell an alias from two genuinely different /// functions — and left anything generating per-function documentation writing several unrelated /// accounts of one target. /// /// **Scope: `core` + `high_confidence`, across the two tiers rather than within each.** `experimental` is /// excluded because its names are unverified guesses, so a shared target there is not evidence of a /// shared meaning — that band states the converse relation (one name guessed at several addresses) /// through `provenance.collision`. /// /// **A bare-slot entry is never grouped**, and that is a real gap rather than an absence of aliases: an /// `offset` with no `class` names no vtable, so two of them carrying slot 3 are not evidence of anything. /// Grouping them would put every unbound slot-3 entry in one bucket — 1,080 CS2 names in 70 fictitious /// groups, measured. Only class-bound offsets and signatures are grouped. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub aliases: Vec, } /// A function's SysV-AMD64 argument footprint, read out of the target binary rather than declared: how many /// integer and float registers it takes as inputs, whether arguments also spill to the stack, and how it /// returns. This is what makes a *declared* prototype checkable — a declaration whose arity contradicts the /// footprint does not describe this build, and calling through it would load the wrong registers. /// /// A lower bound, never an over-count (a forwarding thunk reads no argument register of its own), so a /// disagreement is worth review rather than an automatic rejection. #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct AbiShape { /// Integer/pointer arguments, including an implicit `this`. Caps at 6 — the SysV register budget. pub int: u8, /// Floating arguments (XMM0..7). pub float: u8, /// Arguments also arrive on the stack: the real arity exceeds the register budget, so a caller filling /// only registers is wrong. #[serde(default, skip_serializing_if = "is_false")] pub stack: bool, /// Return class token: `ret=void` | `ret=int` | `ret=float` | `ret=byval` | `ret=?` (undetermined). /// `ret=byval` is the sret case — UNSAFE to blind-call, since the caller must pass an output buffer. pub ret: String, } fn is_false(b: &bool) -> bool { !*b } /// A catalogued function the derivation could not confidently produce — kept in-file (never a shipped /// locator) so the monolith is the complete catalogue picture. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct Unresolved { pub reason: String, // "sig-drifted" | "offset-low-conf" | "unresolved" | … pub detail: String, } /// Entry counts per section — a struct (not a map) so it serializes in this logical order, deterministically. #[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)] pub struct Counts { pub core: usize, pub high_confidence: usize, pub experimental: usize, pub unresolved: usize, } /// The monolith's intrinsic release identity. NO wall-clock field — volatile release metadata (`produced_at`, /// `status`, urls, sha256, `based_on`) lives in the per-buildid manifest, so the monolith is fully /// byte-reproducible. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct MonoMeta { pub game_key: String, pub game: String, // display name pub source_build: String, /// `--` — the buildid is embedded here; the per-buildid MANIFEST carries it as its /// own field (volatile metadata is kept OUT of this byte-reproducible monolith). pub version: String, pub counts: Counts, /// How many distinct functions carry more than one shipped name, and how many names that accounts for /// — the two halves of [`MonoEntry::aliases`] seen from the release's side. Counted over /// `core` + `high_confidence` together, since a group routinely spans the two tiers. /// /// Worth reading before treating the tier counts as a function count: on CS2 roughly 5% of the resolved /// surface is several names on one target, so `counts.core + counts.high_confidence` over-counts /// FUNCTIONS by about that much while being exactly right about NAMES, which is what it says. #[serde(default)] pub alias_groups: usize, #[serde(default)] pub aliased_names: usize, } /// The full derived gamedata for one build — four confidence tiers, each a key-sorted map. [`merge`] /// flattens it into the shipped artifact's `functions`, where the tier a name came from rides on the /// record instead of deciding which map it lives in. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct Monolith { pub meta: MonoMeta, pub core: BTreeMap, pub high_confidence: BTreeMap, pub experimental: BTreeMap, pub unresolved: BTreeMap, } /// Which of the monolith's three SHIPPABLE tiers a render includes — cumulative, most-confident first /// (`unresolved` is never rendered; it has no locator). The `--tier` arg of `source2rosetta-gen`. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum TierSelect { /// `core` only — the guaranteed, live-validated set. Core, /// core + high_confidence — adds the promoted name-extrapolations. HighConfidence, /// core + high_confidence + experimental — every locatable guess. Experimental, } impl TierSelect { /// Parse the `--tier` id (the monolith tier names, plus a couple of intuitive aliases). pub fn from_id(s: &str) -> Option { match s { "core" => Some(TierSelect::Core), "high_confidence" | "high-confidence" | "stable" => Some(TierSelect::HighConfidence), "experimental" | "full" => Some(TierSelect::Experimental), _ => None, } } } /// Every `--tier` id `from_id` accepts (canonical names) — for help text. pub const TIER_IDS: &[&str] = &["core", "high_confidence", "experimental"]; impl Monolith { /// Flatten the tiers up to `select` into one `name -> Entry` map — the input the flat framework emitters /// (metamod/modsharp/swiftly/plugify) consume. Drops confident-bad entries (`validated == Some(false)`); /// `unresolved` is never included (no locator); a more-confident tier wins a name collision. pub fn select(&self, select: TierSelect) -> Gamedata { let mut gd = Gamedata { entries: BTreeMap::new(), game_key: self.meta.game_key.clone(), }; let mut add = |m: &BTreeMap| { for (name, e) in m { if e.validated == Some(false) { continue; } gd.entries .entry(name.clone()) .or_insert_with(|| e.locator.clone()); } }; add(&self.core); if select != TierSelect::Core { add(&self.high_confidence); } if select == TierSelect::Experimental { add(&self.experimental); } gd } } // =========================================================================================== // The binding registry — what the binary DECLARES about its own callable surface, as opposed to what the derivation infers about it: Valve registers every Pulse // binding and entity-IO input with a name, author-facing documentation, and call metadata, and this is // that data read back out. Kept OUT of the monolith on purpose — the monolith answers "where is this // function", this answers "what may I do with it", and only the first belongs in a gamedata file. // =========================================================================================== /// How a Pulse binding is invoked — the receiver question, which decides whether a caller needs an /// entity at all. Mutually exclusive by construction (the two flag bytes are never set together). #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "kebab-case")] pub enum BindingKind { /// A free function on a Pulse library class (`CPulseMathlib::Sin`) — no receiver. Library, /// A method on an entity API (`CBaseEntityAPI::GetAbsOrigin`) — needs an instance receiver. Instance, /// A Pulse cell's own entry point (`CPulseCell_Step_DebugLog::Run`) — invoked by the VM as it walks /// a graph, not called by a graph author. Cell, } /// The call metadata Valve records for a binding — a typed graph VM cannot register a binding without /// knowing how it may be invoked, so this is the engine's own policy, read back rather than reasoned out. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct CallPolicy { pub kind: BindingKind, /// The binding writes state. The const-correctness axis: a caller that only observes can be run /// where a mutation may not be. pub mutates: bool, /// The binding may suspend the calling cursor instead of completing within the frame. pub blocking: bool, /// The two metadata words exactly as read, so a consumer can re-derive meaning if a later build /// repurposes a bit rather than silently inheriting this decoding. pub raw: [u32; 2], } /// One Pulse binding: where it is, what Valve calls it, and how it may be invoked. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct Binding { /// The module registering it — `server`, `pulse_system`, … pub library: String, /// The author-facing label ("Get Abs Origin"). #[serde(default, skip_serializing_if = "Option::is_none")] pub display: Option, /// The author-facing documentation ("The entity origin (absolute)."). #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, pub policy: CallPolicy, /// The binding's declared parameters, in order, recovered from the descriptor accessor's own /// initializer. Pulse is a TYPED graph VM, so this is the engine's own statement of how the /// binding is called — not a transfer from another game and not a 2018-era declaration. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub params: Vec, /// The values it hands back. Pulse models returns as named out-parameters, so this is a LIST: /// usually one `retval`, empty for a void binding, occasionally several. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub returns: Vec, /// Whether the signature above was recovered at all. An empty `params` on an untyped binding means /// "not read"; on a typed one it means "takes nothing", and only this field separates them. pub typed: bool, /// Address of the binding's DESCRIPTOR ACCESSOR in this build — a lazy-init singleton returning the /// static descriptor, not the bound function. It is the anchor a runtime walks to reach the /// descriptor; it is NOT a locator for the named method, and no shipped gamedata entry points at it. /// For an address that IS callable, see [`Binding::shim`]. pub descriptor: String, /// Address of the binding's INVOCATION SHIM in this build — the record's third code pointer, and /// unlike `descriptor` a real entry point. One per binding, never shared. /// /// Calling it dispatches through Valve's own marshalling, which honours the DECLARED parameter types /// in `params`: a value written into the argument blob is consumed according to its `PulseValueType_t`, /// so a caller cannot smuggle a mistyped argument past it. Absent when the record's slot holds no /// executable code. #[serde(default, skip_serializing_if = "Option::is_none")] pub shim: Option, /// How to call [`Binding::shim`], and what a host must supply. Absent when there is no shim. #[serde(default, skip_serializing_if = "Option::is_none")] pub call: Option, } /// The invocation shim's calling contract: fixed across every binding, with a per-binding statement of /// which slots that particular shim reads. /// /// The signature is **seven integer arguments returning int**. Slot 5 (`r8`) is an array of POINTERS to the /// argument values, element *k* at `+8+8k`. Slot 7 (the first stack slot) is the output sink. Slot 4 /// (`rcx`) is a Pulse host-service context, which is VM-owned. The return is `0` on dispatch and `-2` when /// an entity-handle argument fails to resolve. #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct ShimCall { /// What a host must supply beyond the argument array, decoded from `reads`: /// /// * `args-only` — nothing else. The remaining slots may be null; **validated by calling every /// eligible binding in both games.** This is the callable tier. /// * `output-sink` — it returns a value, so it writes through a register-file object a host does not /// have. Read the state through the `schema` section instead; the Pulse getters are redundant /// with schema fields. /// * `pulse-context` — needs a live `CPulseExecCursor` / graph instance. Not host-callable. /// * `other-slots` — reads an argument slot whose role is not established (the `CPulseCell_*` /// family, which are graph NODE implementations rather than API bindings). Not host-callable. pub needs: String, /// The argument slots this shim was measured to read, named in SysV order — the raw fact `needs` is /// decoded from, kept beside it so a build that changes the contract can be re-read rather than /// silently mis-labelled. The same rule `flags_raw` follows. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub reads: Vec, } /// One Pulse parameter or return value, as the binding declares it. /// /// Defined here rather than beside the reader so the deriver-free core crate can carry it: `gen` emits /// these, and nothing about the shape depends on how it was read out of the binary. #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct PulseParam { /// The author-facing parameter name — `_Target` for the receiver, `retval` for a return. pub name: String, /// The `PulseValueType_t` enumerator, as the binary states it. Join it to the `enums` section of /// the `schema` section's `enums` for the spelling; the raw value is kept because that is the fact. #[serde(rename = "type")] pub ty: i32, /// The schema type the value refers to, where the binding NAMES one — which enum a /// `PVAL_SCHEMA_ENUM` is, which struct an opaque handle wraps. Absent for the self-describing /// types, and absent for a `PVAL_EHANDLE`'s entity class, which the initializer does not state — /// see `entity_class` for where that comes from instead. #[serde(default, skip_serializing_if = "Option::is_none")] pub type_name: Option, /// The entity class behind a `PVAL_EHANDLE` — `func_mover`, `basemodelentity`. NOT read from the /// initializer, which never states it: it is Valve's own naming (`mappings/ehandle-classes.json`, /// harvested from the published metadata) propagated across the parameters this build proves are /// the same type. See [`PulseParam::type_token`] for what proves it. #[serde(default, skip_serializing_if = "Option::is_none")] pub entity_class: Option, /// An opaque per-build token identifying the parameter's CONCRETE type: the address of that type's /// destructor, which the initializer stores beside the name. Two parameters carry the same token /// exactly when they are the same type — including the entity class behind a `PVAL_EHANDLE`, which /// nothing else in the binary distinguishes. /// /// NOT serialized, deliberately. An address is meaningless outside the build it was read from, so /// shipping it would invite a consumer to key on something that moves every release. It exists to /// carry the grouping from the reader to the stage that names it, and no further. #[serde(skip)] pub type_token: u64, } /// One entity-IO input handler: the name a map fires, and the C++ method that answers it. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct EntityInput { /// The input name entity IO addresses — `Kill`, `Enable`, `SetSpeed`. pub input: String, /// The class that owns this handler, recovered from the FIELD descriptors sharing its datadesc /// array: a field is a `(member, offset)` pair the SchemaSystem states independently, so the class /// whose schema contains every pair in the array owns it. Absent where the array's fingerprint fit /// several classes or none — `InputEnable` is a distinct handler on 48 classes and guessing between /// them is the thing this exists to stop. #[serde(default, skip_serializing_if = "Option::is_none")] pub class: Option, /// The C++ handler — `InputKill`. NOT class-qualified: the record carries no owning class, which is /// also why the same handler name legitimately appears at many addresses here. pub handler: String, pub library: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub abi: Option, pub addr: String, } /// One registered console command: the name a server operator or a mod types, and the function that /// answers it. Unlike the Pulse registry this carries a real locator — the handler comes from the same /// registration call as the name, not from a descriptor accessor beside it. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct ConsoleCommand { /// The console-facing name, exactly as Valve compiled it — `bot_add`, `+bugvoice`. pub name: String, pub library: String, /// Valve's own help text; absent when the registration passes none. #[serde(default, skip_serializing_if = "String::is_empty")] pub description: String, /// The flag bits whose meaning is measured against Valve's published dump. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub flags: Vec, /// The raw flags word, kept beside the decoding so a build that repurposes a bit can be re-read /// instead of silently mis-labelled — the same rule the Pulse policy word follows. pub flags_raw: String, /// How the registration passed its callback: `direct`, `interface` or `member`. Says how much /// indirection produced the address, which is the honest measure of how far this is from the /// instruction stream. pub form: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub abi: Option, pub addr: String, } /// One ConVar the module registers — the configuration half of the console surface. /// /// Emitted for the METADATA, not as a locator: a consumer finds a convar by name at runtime /// (`ICvar::FindConVar`) with no gamedata at all, so the name alone would add nothing. The flags are the /// payload — `cheat`, `replicated`, `release` are engine-DECLARED authority, and a host deciding what a /// module may change is better served by what the engine says than by a hand-maintained allowlist. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct ConVar { pub name: String, pub library: String, #[serde(default, skip_serializing_if = "String::is_empty")] pub description: String, /// FCVAR bits with a measured meaning — the same space console commands use. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub flags: Vec, /// The raw flags word. Empty when this registrar had no identifiable flags argument, which is honest /// about the gap rather than reporting a zero that would read as "no flags set". #[serde(default, skip_serializing_if = "String::is_empty")] pub flags_raw: String, /// Address of the ConVar object. It lives in `.bss`, so it holds nothing on disk — it is the anchor a /// runtime walks to the live value, and what tells two registrations of one name apart. pub addr: String, } /// One entity-IO output: an event an entity fires, and where its subscriber list lives on the instance. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct EntityOutput { /// The entity-IO name a map or a mod wires to, e.g. `OnStartTouch`. pub output: String, /// The member holding it, e.g. `m_OnStartTouch` — usually the name with `m_` prepended, but not /// reliably (`BombExplode` lives on `m_OnBombExplode`), so both ship. pub member: String, pub library: String, /// Byte offset of the member within its entity — an output is data, not a function, so this is the /// locator. (`CEntityIOOutput` is 24 bytes; see the `types` section of the schema artifact.) pub offset: u32, /// The class the member lives on, recovered by joining `(member, offset)` against the SchemaSystem. /// Without it an offset is unusable wherever a name repeats: `OnBreak` exists at three different /// offsets on three different classes, and reading the wrong one runs ~1 KB past the intended member /// on a live entity. `None` only when the schema does not describe the member. /// /// NB the member's TYPE is not carried here: the offline schema reader recovers names and offsets but /// not types (those are runtime-resolved), and a handful of outputs are not the plain 24-byte /// `CEntityIOOutput` — `CLogicCase::m_OnCase` is `CEntityIOOutput[32]`. Join `class` + `member` /// against the `schema` section for the type before striding one. #[serde(default, skip_serializing_if = "Option::is_none")] pub class: Option, } /// The declared callable surface for one build. [`merge`] folds the function-keyed parts of it onto /// the functions they describe and keeps the rest under [`Surfaces`]. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct Bindings { pub meta: BindingsMeta, /// Pulse bindings, keyed by their fully-qualified `Class::Method`. pub pulse: BTreeMap, /// Entity-IO inputs, as a LIST: the handler name is not unique (one `InputEnable` per class), so /// there is no honest key to map them by. pub entity_inputs: Vec, /// Entity-IO outputs — the events an entity fires, as a LIST for the same reason as the inputs. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub entity_outputs: Vec, /// Map classname -> the C++ class it constructs (`func_door` -> `CBaseDoor`). The join between the /// vocabulary a level designer writes and the classes the `schema` section describes. No addresses: /// the factory record binds names to names, not to a function. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub entity_classes: BTreeMap, /// Console commands, as a LIST: a handful of names are registered by more than one library, so /// there is no honest key to map them by either. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub commands: Vec, /// ConVars, a LIST for the same reason: one name can be registered by more than one library. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub convars: Vec, /// VScript bindings — the surface Valve exposes to Lua, keyed by the SCRIPT-facing name a content /// author types. A LIST rather than a map because a name is only unique per class, and the owning /// class is not recoverable offline. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub vscript: Vec, } /// One function Valve exposes to the script VM. /// /// The fourth surface the binary documents about itself, and the only one that pairs a script-facing /// name with a C++ name, an English description AND a return type in one record. It is disjoint from /// everything else shipped here: measured against the catalogue, not one of the 1,652 Dota /// implementations shares an address with a catalogued entry, and no name is shared either — a /// `Script_TakeDamage` is a script-facing WRAPPER, a different function from the `TakeDamage` it wraps. /// So these are additive, never a second account of something already described. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct VScriptBinding { /// What a Lua author calls (`TakeDamage`). pub name: String, /// The class that owns this member (`CDOTA_BaseNPC`). /// /// **Live-only, and absent from an offline build.** The class descriptor reaches the registration /// through a register loaded from memory rather than a `lea`, so constant propagation recovers it for /// none of the bindings; a running server resolves it through the record's owner pointer. This is the /// same shape as a schema field's TYPE, which is a null placeholder on disk and is why an offline run /// ships no typed netvars either. /// /// Consumers that group by class — `api.json`, the `.d.ts` the Dota ecosystem publishes — need this /// and cannot be rendered from a full build's output alone if it is missing. #[serde(default, skip_serializing_if = "Option::is_none")] pub class: Option, /// The C++ binding this resolves to (`Script_TakeDamage`) — and the key under which the /// implementation is folded into the catalogue, where it exists as a locator. pub cpp: String, pub library: String, /// Valve's own English description, where the registration supplies one. #[serde(default, skip_serializing_if = "String::is_empty")] pub description: String, /// The return type, decoded. Absent when the raw word is outside the corroborated set. #[serde(default, skip_serializing_if = "Option::is_none")] pub ret: Option, /// The raw return-type word, kept beside the decoding so a build that renumbers `ScriptDataType_t` /// can be re-read rather than silently mis-labelled — the rule `flags_raw` already follows. pub ret_raw: u16, /// The implementation address, or the vtable slot when the registration binds a virtual member. #[serde(default, skip_serializing_if = "Option::is_none")] pub addr: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub vtable_slot: Option, /// The prose for the function this binding resolves to — see [`FunctionRecord::doc`]. /// /// **Never on disk**, in either direction: it is joined at VIEW time by [`Rosetta::vscript`], which /// reads it off the record the row was folded onto. That is the whole point of carrying it on the /// row rather than looking it up by name in an emitter: an UNJOINED row bears a name that belongs /// to another module's function, so a name lookup would hand it prose about code it is not. #[serde(skip)] pub doc: Option, } /// The binding registry's intrinsic identity (no wall-clock field, same rationale as [`MonoMeta`]). #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct BindingsMeta { pub game_key: String, pub source_build: String, pub pulse: usize, /// Of those, how many carry a recovered typed signature. #[serde(default)] pub pulse_typed: usize, /// Of those, how many carry a HOST-CALLABLE invocation shim (`call.needs == "args-only"`). #[serde(default)] pub pulse_callable: usize, pub entity_inputs: usize, #[serde(default)] pub entity_outputs: usize, #[serde(default)] pub entity_classes: usize, #[serde(default)] pub commands: usize, #[serde(default)] pub convars: usize, /// VScript bindings recovered. #[serde(default)] pub vscript: usize, /// Of those, how many were attributed to an owning class. Zero on an offline build by construction — /// the class is only readable from a running server. #[serde(default)] pub vscript_classed: usize, /// Of those, how many folded into the catalogue as a locator. Lower than `vscript` by the /// bindings whose implementation did not resolve and the handful whose C++ name is registered at /// more than one address — dropped, not guessed, exactly as the datadesc handlers are. #[serde(default)] pub vscript_located: usize, } impl Bindings { pub fn is_empty(&self) -> bool { self.pulse.is_empty() && self.entity_inputs.is_empty() && self.entity_outputs.is_empty() && self.entity_classes.is_empty() && self.commands.is_empty() } } // =========================================================================================== // The prototype manifest — what a function TAKES, which a locator deliberately does not answer: a locator says where a function is, a prototype says how to call it, and // the two have different sources and different lifetimes. Declarations are static and human-sourced; // the VERDICT on each one is re-measured against every build. // =========================================================================================== /// The verdict on a declared prototype, judged against the footprint measured in THIS build. #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "kebab-case")] pub enum AbiStatus { /// The declared arity matches the measured footprint — safe to call through. Verified, /// The callee reads a register the declaration does not mention. The declaration does not describe /// this build and MUST NOT be called through: such a call resolves, passes live validation, and then /// leaves a register the callee reads unset. Mismatch, /// The declaration passes registers the callee never reads, and contradicts it in no register class. /// /// NOT a mismatch, and separating the two is the point. The measured footprint is a documented LOWER /// bound — a callee that ignores an argument, a forwarding thunk that reads none of its own, an /// empty virtual override — so measuring FEWER arguments than declared is expected behaviour rather /// than evidence against the declaration. `CBaseEntity::SetAbsAngles` is the shape: declared /// `(this, float, float, float)`, measured `int=0 float=3`, every float agreeing exactly and only /// the unread `this` differing. Calling through one merely loads a register nobody reads, which is /// the opposite of the failure `Mismatch` names. 81 of CS2's 140 former mismatches are this. LowerBound, /// No measurement available to check it against. Unverified, /// Overloads the measurement could not separate. Ambiguous, /// A return type was declared and a parameter list was not, so there is no arity claim for the /// binary to confirm or refute. `CALL_VIRTUAL(RET, …)` sites are the source: they say what comes /// back and pass VALUES rather than types, so nothing about the signature can be read off them. ReturnOnly, } impl AbiStatus { pub fn as_str(self) -> &'static str { match self { AbiStatus::Verified => "verified", AbiStatus::Mismatch => "mismatch", AbiStatus::LowerBound => "lower-bound", AbiStatus::Unverified => "unverified", AbiStatus::Ambiguous => "ambiguous", AbiStatus::ReturnOnly => "return-only", } } } /// One function's declared prototype and the verdict this build gives it. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct AbiEntry { /// Which shipped tier the function is in. Cleared when folded onto a [`FunctionRecord`], which /// states it already. #[serde(default, skip_serializing_if = "String::is_empty")] pub tier: String, /// `exact` — the declaration names this function; `bare-name` — it names the same METHOD on some /// class, claimed only because exactly one declaration bears that name and a measurement could /// adjudicate. pub matched_by: String, pub status: AbiStatus, /// Declared parameter TYPES — the thing a machine-derived arity cannot supply. #[serde(default, skip_serializing_if = "Option::is_none")] pub params: Option>, /// Set when `params` is the FULL register-visible argument list, receiver included, because the /// declaration was a function-pointer type rather than a mangled symbol. Absent means `this` is not /// in the list and a caller has to supply it — the convention every Itanium-derived entry uses. #[serde(default, skip_serializing_if = "Option::is_none")] pub params_complete: Option, #[serde(rename = "const", default, skip_serializing_if = "Option::is_none")] pub is_const: Option, /// A DECLARED return type where one exists, else the measured register class — a far weaker /// statement (see [`AbiShape`]), and one that is not evidence about the declared type. #[serde(default, skip_serializing_if = "Option::is_none")] pub ret: Option, /// Omitted on an `ambiguous` verdict, where no declaration was chosen. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub provenance: Vec, /// The measured SysV footprint the verdict was reached against. #[serde(default, skip_serializing_if = "Option::is_none")] pub derived: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub note: Option, /// Every signature the declarations offered, whenever there was more than one — whether the /// measurement went on to pick between them (see `note`) or could not, which is `ambiguous`. #[serde(default, skip_serializing_if = "Option::is_none")] pub overloads: Option>>, /// The vtable slot this build's shipped locator resolves through, present exactly when the locator /// is a vtable offset that LIVE VALIDATION confirmed. /// /// It states something no measurement can: a slot is only reachable through an object, so the /// function HAS a receiver even where the footprint cannot see one. That case is not marginal — /// a getter that returns a constant never reads `this`, and backward liveness reads that as no /// argument at all, so `CBaseDoor::GetDataDescMap` measures `int=0` while genuinely taking one. /// /// Live validation is part of the condition rather than a separate check, because it is what rules /// out the one way an `offset` locator can fail to be a vtable slot: a carried MEMBER offset under a /// name the deriver could not classify, which the live oracle reports as `Unknown`/`Oob` instead of /// `Live`. An offline derive has no such evidence and therefore states no `vtable` at all — /// a smaller emittable set, never a guessed receiver. #[serde(default, skip_serializing_if = "Option::is_none")] pub vtable: Option, /// The prose for this function — see [`FunctionRecord::doc`]. **Never on disk**: joined at VIEW /// time by [`Rosetta::abi_manifest`], off the record this prototype belongs to, so that the call /// sites a framework generates can carry it into an editor's tooltip. #[serde(skip)] pub doc: Option, } impl AbiEntry { /// Drop what the function record states for itself. The tier and the measured footprint the verdict /// was reached against are both fields of the record this is folded onto — carrying them here too /// would give one fact two homes, and two homes is how they come to disagree. fn into_record_prototype(mut self) -> AbiEntry { self.tier.clear(); self.derived = None; self } /// An entry with only the fields every verdict carries — the base for functional-update construction. pub fn blank() -> Self { Self { tier: String::new(), matched_by: String::new(), status: AbiStatus::Unverified, params: None, params_complete: None, is_const: None, ret: None, provenance: Vec::new(), derived: None, note: None, overloads: None, vtable: None, doc: None, } } } /// The prototype manifest's identity plus its verdict tally. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct AbiMeta { pub game_key: String, pub source_build: String, /// Verdict tally — `status:verified`, `core:resolved`, `high_confidence:none`, … pub counts: BTreeMap, } /// Every declared prototype this build could judge — [`merge`] folds each onto its function as /// [`FunctionRecord::prototype`]. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct AbiManifest { pub meta: AbiMeta, pub functions: BTreeMap, } /// One typed schema field (the field NAME is the map key). `offset` is static; `ty`/`kind`/`size` are /// runtime-resolved (empty/zero when derived offline without a live process). #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct Field { pub offset: i32, #[serde(rename = "type", default, skip_serializing_if = "String::is_empty")] pub ty: String, #[serde(default)] pub kind: FieldKind, pub size: usize, pub name_hash: u64, } /// How a schema field holds its value — a closed runtime domain (Source-2 `CSchemaType` category). /// Serializes to lowercase tokens, the values a schema consumer expects. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "snake_case")] pub enum FieldKind { /// Builtin / atomic / declared class / declared enum — held inline (the common case). #[default] Ref, /// A pointer to the value. Ptr, /// A fixed-size inline array. FixedArray, } /// A direct base class and the `this`-adjustment to reach it. The schema records these, and a consumer /// needs them for the artifact's most basic question: `CCSPlayerPawn.m_iHealth` is not a field OF /// `CCSPlayerPawn` — it is inherited, and only the base chain says where to look. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct BaseClass { pub name: String, pub offset: u32, } /// How a type travels when passed BY VALUE under the SysV-AMD64 convention — the fact a caller needs /// that a field offset cannot supply. #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "kebab-case")] pub enum SysvClass { /// Travels in integer registers: pointers, handles, and small aggregates with any non-float member. Integer, /// Travels in SSE registers — an aggregate of 16 bytes or less whose members are all floating-point. /// `Vector` (3 floats) is the case that bites: by value it costs TWO SSE registers, by reference one /// integer register. Sse, /// Larger than 16 bytes, so it is passed in memory (effectively by reference) and returned through a /// hidden pointer. Size alone settles this one. Memory, /// Size known but composition unknown, or size unknown — a caller must not guess. Unknown, } /// Where a type's size came from — the honesty axis, since the two routes carry different confidence. #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "kebab-case")] pub enum LayoutSource { /// The SchemaSystem states the class's instance size outright. Schema, /// Inferred from the distance to the next field across many classes — exact for every primitive whose /// size is independently known, but an inference nonetheless. FieldGap, /// Declared in the deriver, for the closed set of engine primitives the schema does not register. Declared, } /// What a consumer needs to pass or hold a value of some type: how big it is, and how it travels. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct TypeLayout { pub size: usize, pub sysv: SysvClass, pub source: LayoutSource, /// How many field observations backed a `field-gap` size, and how many agreed — omitted for the other /// sources, where the size is stated rather than inferred. #[serde(default, skip_serializing_if = "Option::is_none")] pub observations: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub agreement: Option, } /// One enumerator: the name and the value it stands for. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct EnumValue { pub name: String, pub value: i64, } /// A registered enum — the semantic vocabulary behind an integer field. `m_MoveType = 2` is only /// meaningful as `MOVETYPE_WALK`, and a register footprint can never recover that. /// /// Unlike a field's TYPE, this is static data: the SchemaSystem records enum bindings in the binary, so /// these are read from the image rather than from the running process. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct EnumDef { /// Underlying integer width in bytes — 1 for `MoveType_t`, 4 for `gear_slot_t`. Recorded by the /// binding, so the distinction is read rather than assumed. pub size: u8, /// Enumerators in DECLARATION order. A list, not a map: names are unique but values are not, since /// aliases (`MOVETYPE_LAST` / `MOVETYPE_INVALID`) legitimately share one. pub values: Vec, } /// The typed schema, ships as the artifact's `schema` section. Merges field offsets with runtime types: /// class -> field -> [`Field`], plus the enum vocabulary those fields refer to. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct Schema { pub meta: SchemaMeta, pub classes: BTreeMap>, /// Direct base classes per class. `classes` holds each class's OWN fields only, so resolving an /// inherited member means walking this. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub bases: BTreeMap>, /// Registered enums by name. Empty on an older artifact that predates the enum walk. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub enums: BTreeMap, /// Size + SysV class for every type the fields above refer to — what a caller needs in order to pass /// one, which neither an offset nor a register footprint can supply. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub types: BTreeMap, } /// The typed schema's intrinsic identity (no wall-clock field, same rationale as [`MonoMeta`]). #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct SchemaMeta { pub game_key: String, pub source_build: String, pub typed: usize, pub untyped: usize, /// Registered enums recovered (0 on an artifact that predates the enum walk). #[serde(default)] pub enums: usize, /// Types with a recovered size + SysV class. #[serde(default)] pub types: usize, } // =========================================================================================== // The merged release artifact — the shipped `rosetta-.json`. ONE record per function, joining // what the four derivation stages each know about it: where it is, what its machine code was measured // to take, what a declaration says it takes, what the binary declares may be done with it, and what it // means. Those are separate STAGES with separate confidence, not separate artifacts: keyed by name, // they describe one thing, and a consumer answering "may I call this, and how" needed all four open at // once to find out. // // The split that survives is between what is FUNCTION-KEYED and what is not. A Pulse binding names no // C++ function, an entity output is a member rather than a method, and a classname maps names to names // — none of those has a function record to live on, so they stay whole under `surfaces`. // =========================================================================================== /// Which shipped tier a function record came from — the section names of the tiered catalogue, kept as /// a per-record field now that the tiers are no longer separate maps. #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "snake_case")] pub enum Section { Core, HighConfidence, Experimental, } impl Section { /// The section's id, the same string the serialization emits. pub fn as_str(self) -> &'static str { match self { Section::Core => "core", Section::HighConfidence => "high_confidence", Section::Experimental => "experimental", } } } /// What a function is FOR, in plain language, for a reader who has the locator and still does not know /// what the function does. /// /// Keyed on the NAME rather than any locator, and deliberately SIGNATURE-FREE: arity, types and verdicts /// live in [`FunctionRecord::prototype`] and are joined at render time, so a prototype changing under a /// new build cannot make a description wrong. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct Description { pub text: String, /// How the text was arrived at — `derived` where the surrounding facts fix the meaning mechanically, /// `generated` otherwise. Valve's own prose is NOT carried here: it rides the binding it came with, /// and duplicating it would create a second place for one fact to go stale. pub source: String, } /// One function's prose as an emitter prints it: the text, and where it came from. /// /// Distinct from [`Description`] because it is a RESOLVED view over every place prose can live. A /// `Description` is what this artifact authored; a `Doc` may equally be Valve's own text, lifted off the /// binding that carried it. `source` says which, and every emitter prints it beside the text — a plugin /// author acting on a sentence must never have to guess whether Valve wrote it or this project did. #[derive(Clone, Debug)] pub struct Doc { /// Collapsed to ONE line (see [`one_line`]): every target embeds this in a comment, and two of the /// three comment syntaxes involved end at a newline. pub text: String, /// [`Doc::VALVE`], or the authored [`Description::source`] id. pub source: String, } impl Doc { /// The `source` of text the binary itself carries. Not a value [`Description::source`] ever holds — /// Valve's prose rides its binding — so it cannot collide with an authored id. pub const VALVE: &'static str = "valve"; } /// Collapse every whitespace run to a single space. /// /// Load-bearing rather than cosmetic: 78 CS2 and 82 Dota descriptions contain a newline, and both the /// C# `///` and the C++ `//` comment forms END at one — an unflattened description would put the rest /// of Valve's sentence into the generated source as code. pub(crate) fn one_line(s: &str) -> String { s.split_whitespace().collect::>().join(" ") } /// What the binary itself declares may be done with this function, where it declares anything at all. /// /// Internally tagged, because the three cases carry genuinely different fields and a consumer switches on /// which one it got: an entity-IO handler answers a name a map fires, a console handler answers a command /// an operator types, and a VScript binding is the same function exposed to the script VM under another /// name with Valve's own documentation attached. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] #[serde(tag = "kind", rename_all = "kebab-case")] pub enum FunctionBinding { EntityInput { /// The input name entity IO addresses — `Kill`, `Enable`, `SetSpeed`. input: String, /// The owning class where the datadesc array's field descriptors identified one. class: Option, }, Command { command: String, /// Valve's own help text, `null` where the registration passes none. description: Option, /// FCVAR bits with a measured meaning, `null` where the word sets none of them. flags: Option>, /// The raw flags word, kept beside the decoding so a build that repurposes a bit can be re-read. flags_raw: String, /// How the registration passed its callback — `direct`, `interface`, `member`. callback_form: String, }, Vscript { /// What a Lua author types, as opposed to the C++ name this record is keyed by. script_name: String, class: Option, description: Option, ret: Option, ret_raw: u16, }, } /// One function, everything the release knows about it. /// /// The locator is flattened in exactly as the tiered catalogue flattened it, so a consumer that only /// wants an address reads the same shape as before. Everything after it is a different KIND of fact, and /// the field names say which: `measured` is read out of this build's machine code, `prototype` is a human /// declaration judged against that measurement, `binding` is the binary's own declaration about the /// function, and `description` is authored prose. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct FunctionRecord { pub tier: Section, #[serde(flatten)] pub locator: Entry, /// The SysV argument footprint read out of this build — see [`AbiShape`]. Absent when the address /// was not resolvable offline. #[serde(default, skip_serializing_if = "Option::is_none")] pub measured: Option, /// Live-validation verdict, and it is THREE-VALUED: `true` passed, `false` dropped confident-bad, /// `null` not checked here. Always emitted, never skipped — `null` is a statement (an offline build, /// a library the vanilla server does not map, a non-vtable class) and an absent key would leave a /// reader unable to tell it from a field this artifact forgot. #[serde(default)] pub validated: Option, /// The other shipped names for this same function — see [`MonoEntry::aliases`]. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub aliases: Vec, pub provenance: Provenance, /// The declared prototype and this build's verdict on it, where one was declared. #[serde(default, skip_serializing_if = "Option::is_none")] pub prototype: Option, /// What the binary declares may be done with this function — a LIST, because one function can be /// several of them: `AddOutput` is registered on three classes at once, and a console name can be /// registered by more than one library. Keeping only the last would assert one class as the class. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub bindings: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, } impl FunctionRecord { /// The one piece of prose to print above this function, resolved across every place prose can be. /// /// **Valve's own text always wins.** It is a statement by the people who wrote the function, read /// out of the binary's own registries; [`Description`] is this project's reading of the build, and a /// reading has no business overriding the source. So a generated description FILLS A GAP and never /// displaces one — which also means the two can never disagree in a shipped output. /// /// Between Valve's two registries the VScript one wins, because the two describe different things: a /// script binding's description documents the FUNCTION, while a console registration's help text /// documents the COMMAND that reaches it ("`sv_cheats <0/1>` — enable cheats") and is written for an /// operator typing it, not a caller. /// /// `None` where nothing describes the function at all, which is the majority: 5,287 of 8,361 CS2 /// records. An emitter renders those exactly as it did before descriptions existed. pub fn doc(&self) -> Option { // Empty is not documented: a registration that passes an empty help string has said nothing, // and skipping it here is what lets the search fall through to the next binding — and then to // the generated text — instead of stopping on a blank. let valve = |want_vscript: bool| { self.bindings.iter().find_map(|b| { match b { FunctionBinding::Vscript { description, .. } if want_vscript => { description.as_deref() } FunctionBinding::Command { description, .. } if !want_vscript => { description.as_deref() } _ => None, } .filter(|d| !d.is_empty()) }) }; if let Some(text) = valve(true).or_else(|| valve(false)) { return Some(Doc { text: one_line(text), source: Doc::VALVE.to_string(), }); } self.description.as_ref().map(|d| Doc { text: one_line(&d.text), source: d.source.clone(), }) } } /// The typed schema, as a section of the merged artifact. /// /// **Live-only.** Field types are runtime-resolved, so an offline build has no schema to state — which is /// why [`Rosetta::schema`] is an explicit `null` rather than an absent key: absence would be /// indistinguishable from a build that resolved zero classes. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct SchemaSection { pub classes: BTreeMap>, pub bases: Option>>, pub enums: Option>, pub types: Option>, pub meta: Option, } impl From for SchemaSection { fn from(s: Schema) -> Self { fn opt(m: BTreeMap) -> Option> { (!m.is_empty()).then_some(m) } SchemaSection { classes: s.classes, bases: opt(s.bases), enums: opt(s.enums), types: opt(s.types), meta: Some(s.meta), } } } /// The declared surfaces that are NOT function-keyed, so cannot fold into a function record. /// /// Each is here for its own reason rather than as a leftovers bin: a Pulse binding does not name a C++ /// function at all (its `shim` is an entry point into Valve's marshalling, not the bound method), an /// entity output is a member offset rather than a method, a classname binds one name to another, a /// ConVar is configuration rather than code, and an unlocated VScript binding is documentation for a /// function this build could not place. #[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)] pub struct Surfaces { #[serde(default)] pub pulse: BTreeMap, #[serde(default)] pub entity_outputs: Vec, #[serde(default)] pub entity_classes: BTreeMap, #[serde(default)] pub convars: Vec, /// Declared rows that belong to no function record here. See [`Unjoined`]. #[serde(default)] pub unjoined: Unjoined, } /// What the binary declares about functions this build does not describe — the other side of the /// function-keyed joins, kept rather than dropped so the merge loses nothing the deriver read. /// /// Two things put a row here, and they are different in kind. Either **nothing located it**: the /// implementation did not resolve, or the handler's name was ambiguous and was dropped rather than /// guessed. Or **the name belongs to another module's function**: a console name registered by two /// libraries, or a script name registered in three, is several functions, while the catalogue holds one /// entry under that name — so the rows from the other modules describe code this artifact does not /// locate, and asserting them onto the one record it does would be a claim about the wrong function. /// /// Each row keeps its own `library` and `addr`, which is what tells the two cases apart. #[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)] pub struct Unjoined { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub entity_inputs: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub commands: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub vscript: Vec, } /// How many records each join reached — reported so a collapse in any one of them is visible in the /// artifact rather than only in a derive log. #[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)] pub struct Joined { pub prototypes: usize, pub bindings: JoinedBindings, pub descriptions: usize, } #[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)] pub struct JoinedBindings { #[serde(rename = "entity-input")] pub entity_input: usize, pub command: usize, pub vscript: usize, } /// The merged artifact's identity — the catalogue's own release identity plus what the merge joined. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct RosettaMeta { pub game_key: String, pub game: String, pub source_build: String, pub version: String, pub counts: Counts, pub alias_groups: usize, pub aliased_names: usize, pub merged: bool, pub joined: Joined, } /// The shipped `rosetta-.json` — one file, one record per function. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct Rosetta { pub meta: RosettaMeta, pub functions: BTreeMap, /// Catalogued but not produced this build — no locator, so not a function record. pub unresolved: BTreeMap, /// `None` on an offline build, serialized as an explicit `null`. See [`SchemaSection`]. pub schema: Option, pub surfaces: Surfaces, } impl Rosetta { /// The locators alone, at a confidence tier — what a framework's LOADER resolves through. /// /// Drops entries live validation confidently rejected, the same rule the tiered catalogue applied: /// a `validated: false` entry is one the running server disagreed with, and shipping it into a /// loader would hand a consumer an address the deriver already knows is wrong. pub fn gamedata(&self, select: TierSelect) -> Gamedata { let keep = |t: Section| match select { TierSelect::Core => t == Section::Core, TierSelect::HighConfidence => t != Section::Experimental, TierSelect::Experimental => true, }; Gamedata { entries: self .functions .iter() .filter(|(_, r)| keep(r.tier) && r.validated != Some(false)) .map(|(n, r)| (n.clone(), r.locator.clone())) .collect(), game_key: self.meta.game_key.clone(), } } /// The tiered catalogue view — the shape the CS# combined renderer works from, which needs the tiers /// as separate sections in order to banner them differently. pub fn to_monolith(&self) -> Monolith { let mut m = Monolith { meta: MonoMeta { game_key: self.meta.game_key.clone(), game: self.meta.game.clone(), source_build: self.meta.source_build.clone(), version: self.meta.version.clone(), counts: self.meta.counts.clone(), alias_groups: self.meta.alias_groups, aliased_names: self.meta.aliased_names, }, core: BTreeMap::new(), high_confidence: BTreeMap::new(), experimental: BTreeMap::new(), unresolved: self.unresolved.clone(), }; for (name, r) in &self.functions { let section = match r.tier { Section::Core => &mut m.core, Section::HighConfidence => &mut m.high_confidence, Section::Experimental => &mut m.experimental, }; section.insert( name.clone(), MonoEntry { locator: r.locator.clone(), abi: r.measured.clone(), provenance: r.provenance.clone(), validated: r.validated, aliases: r.aliases.clone(), }, ); } m } /// The declared prototypes, as the manifest the call-site emitters consume. /// /// `tier` and `derived` are restored from the record, which is where the merge moved them — the /// receiver test in `render::callable_shapes` reads the measured footprint, so a view that dropped /// it would quietly emit fewer call sites than the data supports. `doc` is joined the same way, and /// is what puts a sentence in a plugin author's editor tooltip rather than only in the artifact. pub fn abi_manifest(&self) -> AbiManifest { AbiManifest { meta: AbiMeta { game_key: self.meta.game_key.clone(), source_build: self.meta.source_build.clone(), counts: BTreeMap::new(), }, functions: self .functions .iter() .filter_map(|(n, r)| { let p = r.prototype.as_ref()?; Some(( n.clone(), AbiEntry { tier: r.tier.as_str().to_string(), derived: r.measured.clone(), doc: r.doc(), ..p.clone() }, )) }) .collect(), } } /// The VScript registry, whole: the members folded onto functions plus the ones that joined nowhere. /// /// Both belong in a rendered API — a member Valve documents is part of the surface a script author /// sees whether or not this build located its implementation. /// /// A folded row also picks up its function's [`doc`](FunctionRecord::doc); an unjoined one does not, /// and must not. Its C++ name may be a name another module's function also bears, so prose looked up /// by that name would describe code this row is not — the same trap the merge itself refuses. pub fn vscript(&self) -> Vec { let mut out: Vec = self .functions .iter() .flat_map(|(cpp, r)| { r.bindings.iter().filter_map(move |b| match b { FunctionBinding::Vscript { script_name, class, description, ret, ret_raw, } => Some(VScriptBinding { name: script_name.clone(), class: class.clone(), cpp: cpp.clone(), library: r .locator .signature .as_ref() .map(|s| s.library.clone()) .unwrap_or_default(), description: description.clone().unwrap_or_default(), ret: ret.clone(), ret_raw: *ret_raw, addr: r.provenance.addr.clone(), vtable_slot: None, doc: r.doc(), }), _ => None, }) }) .collect(); out.extend(self.surfaces.unjoined.vscript.iter().cloned()); out } /// The typed schema as its own model — `None` on an offline build, where there is no schema to state. pub fn typed_schema(&self) -> Option { let s = self.schema.as_ref()?; Some(Schema { meta: s.meta.clone().unwrap_or(SchemaMeta { game_key: self.meta.game_key.clone(), source_build: self.meta.source_build.clone(), typed: 0, untyped: 0, enums: 0, types: 0, }), classes: s.classes.clone(), bases: s.bases.clone().unwrap_or_default(), enums: s.enums.clone().unwrap_or_default(), types: s.types.clone().unwrap_or_default(), }) } } /// Fold the derivation's four outputs into one artifact. /// /// A pure function of the models — it reads nothing from disk and infers nothing new, so every field in /// the result is one a stage already produced. That is what makes the shape checkable against an /// independent merge of the same inputs. /// /// Joins are by NAME, and a record that finds no home is not silently dropped: an unjoinable VScript /// row lands in [`Surfaces::unjoined`], so the merge is lossless: every declared row is either on the /// function it describes or stated as a surface, and [`Joined`] counts which. pub fn merge( mono: Monolith, abi: Option, bindings: Option, schema: Option, descriptions: BTreeMap, ) -> Rosetta { let Monolith { meta, core, high_confidence, experimental, unresolved, } = mono; let mut functions: BTreeMap = BTreeMap::new(); for (tier, section) in [ (Section::Core, core), (Section::HighConfidence, high_confidence), (Section::Experimental, experimental), ] { for (name, e) in section { // Most-confident tier first, and FIRST WRITER WINS. The tiers are disjoint upstream, so this // never fires — but if one ever did carry a name twice, keeping the first means a `core` // function cannot be relabelled as an `experimental` guess by iteration order alone. functions.entry(name).or_insert(FunctionRecord { tier, locator: e.locator, measured: e.abi, validated: e.validated, aliases: e.aliases, provenance: e.provenance, prototype: None, bindings: Vec::new(), description: None, }); } } let mut joined = Joined::default(); // The DECLARED prototype beside the MEASURED footprint: two different kinds of fact about one // function, and the verdict on the first is reached against the second. if let Some(abi) = abi { for (name, row) in abi.functions { if let Some(rec) = functions.get_mut(&name) { rec.prototype = Some(row.into_record_prototype()); joined.prototypes += 1; } } } let mut surfaces = Surfaces::default(); if let Some(b) = bindings { // A name is only unique WITHIN a module: `AddOutput` is registered in three libraries and // `cl_particles_dumplist` in two, each a different function, while the catalogue holds one entry // under that name. Attaching every registration to that one record would assert bindings that // belong to code it does not locate — the bare-name trap, from the other direction. So a row is // attached only when the record cannot contradict it: same library, or a record whose locator is // a vtable slot and therefore names no library at all. let joins = |rec: &FunctionRecord, lib: &str| { rec.locator .signature .as_ref() .is_none_or(|sig| sig.library == lib) }; for r in b.entity_inputs { let key = match &r.class { Some(c) => format!("{c}::{}", r.handler), None => r.handler.clone(), }; match functions.get_mut(&key).filter(|rec| joins(rec, &r.library)) { Some(rec) => { rec.bindings.push(FunctionBinding::EntityInput { input: r.input.clone(), class: r.class.clone(), }); joined.bindings.entity_input += 1; } None => surfaces.unjoined.entity_inputs.push(r), } } for c in b.commands { let key = format!("ConCommand::{}", c.name); match functions.get_mut(&key).filter(|rec| joins(rec, &c.library)) { Some(rec) => { rec.bindings.push(FunctionBinding::Command { command: c.name, description: none_if_empty(c.description), flags: (!c.flags.is_empty()).then_some(c.flags), flags_raw: c.flags_raw, callback_form: c.form, }); joined.bindings.command += 1; } None => surfaces.unjoined.commands.push(c), } } for v in b.vscript { match functions .get_mut(&v.cpp) .filter(|rec| joins(rec, &v.library)) { Some(rec) => { rec.bindings.push(FunctionBinding::Vscript { script_name: v.name, class: v.class, description: none_if_empty(v.description), ret: v.ret, ret_raw: v.ret_raw, }); joined.bindings.vscript += 1; } None => surfaces.unjoined.vscript.push(v), } } surfaces.pulse = b.pulse; surfaces.entity_outputs = b.entity_outputs; surfaces.entity_classes = b.entity_classes; surfaces.convars = b.convars; } for (name, d) in descriptions { if let Some(rec) = functions.get_mut(&name) { rec.description = Some(d); joined.descriptions += 1; } } Rosetta { meta: RosettaMeta { game_key: meta.game_key, game: meta.game, source_build: meta.source_build, version: meta.version, counts: meta.counts, alias_groups: meta.alias_groups, aliased_names: meta.aliased_names, merged: true, joined, }, functions, unresolved, schema: schema.map(SchemaSection::from), surfaces, } } /// An empty string is the artifact's way of saying a source supplied nothing; the merged record says so /// with `null` instead, so a reader never has to know which spelling of absence a given surface used. fn none_if_empty(s: String) -> Option { (!s.is_empty()).then_some(s) } #[cfg(test)] mod merge_tests { use super::*; use serde_json::json; /// Fixtures are built by DESERIALIZING the artifact shapes, so a test states what a reader sees on /// disk rather than which constructor the deriver happened to use. fn from serde::Deserialize<'de>>(v: serde_json::Value) -> T { serde_json::from_value(v).expect("fixture parses as the artifact shape") } /// The keys of a serialized object, IN THE ORDER WRITTEN. `serde_json::Value` cannot answer this — /// its map is key-sorted — and the order is part of what the artifact promises, so it is read back /// off the text the way a consumer diffing two releases would see it. fn field_order(json: &str) -> Vec { let (mut keys, mut depth, mut in_str, mut esc, mut cur) = (Vec::new(), 0i32, false, false, String::new()); for c in json.chars() { if in_str { match c { _ if esc => esc = false, '\\' => esc = true, '"' => in_str = false, _ if depth == 1 => cur.push(c), _ => {} } continue; } match c { '"' => { in_str = true; cur.clear(); } ':' if depth == 1 && !cur.is_empty() => keys.push(std::mem::take(&mut cur)), '{' | '[' => depth += 1, '}' | ']' => depth -= 1, _ => {} } } keys } fn mono(entries: serde_json::Value) -> Monolith { from(json!({ "meta": { "game_key": "csgo", "game": "CS2", "source_build": "b", "version": "cs2-1-0", "counts": { "core": 1, "high_confidence": 1, "experimental": 0, "unresolved": 0 } }, "core": entries, "high_confidence": {}, "experimental": {}, "unresolved": {}, })) } #[test] fn merge_folds_every_stage_onto_one_record() { let m = mono(json!({ "ConCommand::bot_add": { "signature": { "library": "server", "linux": "55 48" }, "abi": { "int": 2, "float": 0, "ret": "ret=int" }, "provenance": { "tier": "valve-table" }, "validated": true, } })); let abi: AbiManifest = from(json!({ "meta": { "game_key": "csgo", "source_build": "b", "counts": {} }, "functions": { "ConCommand::bot_add": { "tier": "core", "matched_by": "engine-contract", "status": "verified", "params": ["CCommandContext*"], "ret": "void" } }, })); let bindings: Bindings = from(json!({ "meta": { "game_key": "csgo", "source_build": "b", "pulse": 0, "entity_inputs": 0 }, "pulse": {}, "entity_inputs": [], "commands": [{ "name": "bot_add", "library": "server", "description": "adds a bot", "flags": ["release"], "flags_raw": "0x4", "form": "direct", "addr": "0x1" }], })); let desc: BTreeMap = from(json!({ "ConCommand::bot_add": { "text": "Adds a bot.", "source": "generated" } })); let r = merge(m, Some(abi), Some(bindings), None, desc); assert_eq!(r.meta.joined.prototypes, 1); assert_eq!(r.meta.joined.bindings.command, 1); assert_eq!(r.meta.joined.descriptions, 1); // The locator stays flattened, and every other stage is a named field beside it — which is the // whole shape claim: one record, four kinds of fact, each labelled by where it came from. let rec_json = serde_json::to_string(&r.functions["ConCommand::bot_add"]).unwrap(); assert_eq!( field_order(&rec_json), [ "tier", "signature", "measured", "validated", "provenance", "prototype", "bindings", "description" ] ); let v = serde_json::to_value(&r).unwrap(); let rec = &v["functions"]["ConCommand::bot_add"]; assert_eq!(rec["tier"], "core"); assert_eq!(rec["measured"]["int"], 2); assert_eq!(rec["prototype"]["status"], "verified"); assert_eq!(rec["bindings"][0]["kind"], "command"); assert_eq!(rec["bindings"][0]["command"], "bot_add"); // the prototype sheds what the record already says assert!(rec["prototype"].get("tier").is_none()); assert!(rec["prototype"].get("derived").is_none()); assert_eq!(rec["description"]["source"], "generated"); assert!(v["schema"].is_null()); // offline: an explicit null, never an absent key } #[test] fn a_source_that_supplied_nothing_reads_as_null_not_as_empty() { let m = mono(json!({ "ConCommand::x": { "offset": 3, "provenance": { "tier": "core" } } })); let bindings: Bindings = from(json!({ "meta": { "game_key": "csgo", "source_build": "b", "pulse": 0, "entity_inputs": 0 }, "pulse": {}, "entity_inputs": [], "commands": [{ "name": "x", "library": "server", "flags_raw": "0x0", "form": "direct", "addr": "0x1" }], })); let v = serde_json::to_value(merge(m, None, Some(bindings), None, BTreeMap::new())).unwrap(); let b = &v["functions"]["ConCommand::x"]["bindings"][0]; assert!(b["description"].is_null()); assert!(b["flags"].is_null()); assert_eq!(b["flags_raw"], "0x0"); } #[test] fn a_binding_from_another_library_is_not_asserted_onto_this_function() { // One name, three registrations, three different functions — the catalogue holds the `server` // one. The other two describe code this build does not locate, so they must not become claims // about the function it does. let m = mono(json!({ "AddOutput": { "signature": { "library": "server", "linux": "55" }, "provenance": { "tier": "core" } } })); let row = |lib: &str| { json!({ "name": "AddOutput", "cpp": "AddOutput", "class": "CNativeOutputs", "library": lib, "ret_raw": 0 }) }; let bindings: Bindings = from(json!({ "meta": { "game_key": "csgo", "source_build": "b", "pulse": 0, "entity_inputs": 0 }, "pulse": {}, "entity_inputs": [], "vscript": [row("server"), row("engine2"), row("worldrenderer")], })); let r = merge(m, None, Some(bindings), None, BTreeMap::new()); assert_eq!(r.meta.joined.bindings.vscript, 1); assert_eq!(r.functions["AddOutput"].bindings.len(), 1); assert_eq!(r.surfaces.unjoined.vscript.len(), 2); } #[test] fn the_views_hand_each_emitter_what_it_reads() { let mut m = mono(json!({ "A::keep": { "signature": { "library": "server", "linux": "55" }, "abi": { "int": 2, "float": 0, "ret": "ret=int" }, "provenance": { "tier": "core" }, "validated": true }, "A::rejected": { "offset": 9, "provenance": { "tier": "core" }, "validated": false }, })); // a guess, which `core` must not include m.experimental.insert( "A::guess".into(), from(json!({ "offset": 4, "provenance": { "tier": "low" }, "validated": null })), ); let abi: AbiManifest = from(json!({ "meta": { "game_key": "csgo", "source_build": "b", "counts": {} }, "functions": { "A::keep": { "tier": "core", "matched_by": "exact", "status": "verified", "params": ["int"] } }, })); let r = merge(m, Some(abi), None, None, BTreeMap::new()); // the locator half: tier-filtered, and an entry live validation rejected is not a locator let core = r.gamedata(TierSelect::Core); assert_eq!(core.entries.keys().collect::>(), ["A::keep"]); assert_eq!( r.gamedata(TierSelect::Experimental).entries.len(), 2 // keep + guess; the rejected one stays out at every tier ); // the prototype half: the two fields the merge moved onto the record are restored, because the // receiver test in `render::callable_shapes` reads the measurement let man = r.abi_manifest(); let e = &man.functions["A::keep"]; assert_eq!(e.tier, "core"); assert_eq!(e.derived.as_ref().map(|d| d.int), Some(2)); assert_eq!(man.meta.game_key, "csgo"); } #[test] fn the_script_api_view_carries_the_members_that_joined_and_the_ones_that_did_not() { let m = mono( json!({ "Script_Kill": { "signature": { "library": "server", "linux": "55" }, "provenance": { "tier": "core", "addr": "0x1" } } }), ); let bindings: Bindings = from(json!({ "meta": { "game_key": "csgo", "source_build": "b", "pulse": 0, "entity_inputs": 0 }, "pulse": {}, "entity_inputs": [], "vscript": [ { "name": "Kill", "cpp": "Script_Kill", "class": "CBaseEntity", "library": "server", "description": "Kills it", "ret": "void", "ret_raw": 0 }, { "name": "Gone", "cpp": "Script_Gone", "class": "CBaseEntity", "library": "server", "ret_raw": 0 }, ], })); let r = merge(m, None, Some(bindings), None, BTreeMap::new()); let vs = r.vscript(); assert_eq!(vs.len(), 2); // one on its function, one unjoined — both are surface a script sees let joined = vs.iter().find(|v| v.cpp == "Script_Kill").unwrap(); assert_eq!(joined.name, "Kill"); assert_eq!(joined.description, "Kills it"); assert_eq!(joined.library, "server"); // taken from the record's own locator assert!(vs.iter().any(|v| v.cpp == "Script_Gone")); assert!(r.typed_schema().is_none()); // offline: nothing to render a schema from } #[test] fn valves_own_text_always_wins_and_a_generated_one_only_fills_a_gap() { let vs = |cpp: &str, desc: &str| { json!({ "name": "N", "cpp": cpp, "class": "C", "library": "server", "description": desc, "ret_raw": 0 }) }; let m = mono(json!({ "ConCommand::documented": { "offset": 1, "provenance": { "tier": "core" } }, "ConCommand::help_only": { "offset": 2, "provenance": { "tier": "core" } }, "ConCommand::ours": { "offset": 3, "provenance": { "tier": "core" } }, "ConCommand::silent": { "offset": 4, "provenance": { "tier": "core" } }, })); let bindings: Bindings = from(json!({ "meta": { "game_key": "csgo", "source_build": "b", "pulse": 0, "entity_inputs": 0 }, "pulse": {}, "entity_inputs": [], // Both registries describe the first one. A console help string documents the COMMAND an // operator types; the script registry documents the FUNCTION, so it is the one that wins. "commands": [ { "name": "documented", "library": "server", "flags_raw": "0x0", "form": "direct", "addr": "0x1", "description": "type `documented 1` to do the thing" }, { "name": "help_only", "library": "server", "flags_raw": "0x0", "form": "direct", "addr": "0x2", "description": "Valve's help text" }, ], "vscript": [ // The first binding carries nothing: an empty registration has said nothing, so the // search must fall THROUGH it rather than stop on a blank. vs("ConCommand::documented", ""), vs("ConCommand::documented", "Valve's script prose"), ], })); let desc: BTreeMap = from(json!({ "ConCommand::documented": { "text": "ours, and outranked", "source": "generated" }, "ConCommand::help_only": { "text": "ours, and outranked", "source": "generated" }, "ConCommand::ours": { "text": "a\nreading\tof this build", "source": "generated" }, })); let r = merge(m, None, Some(bindings), None, desc); let doc = |n: &str| r.functions[n].doc(); let d = doc("ConCommand::documented").unwrap(); assert_eq!(d.text, "Valve's script prose"); assert_eq!(d.source, Doc::VALVE); let d = doc("ConCommand::help_only").unwrap(); assert_eq!(d.text, "Valve's help text"); assert_eq!(d.source, Doc::VALVE); // Ours fills a gap, keeps its own source id, and arrives as ONE line whatever it was authored // as — every target embeds it in a comment, and two of the three end at a newline. let d = doc("ConCommand::ours").unwrap(); assert_eq!(d.text, "a reading of this build"); assert_eq!(d.source, "generated"); // Nothing describes it, which is the majority case: an emitter renders it as it always did. assert!(doc("ConCommand::silent").is_none()); } #[test] fn prose_reaches_the_emitters_by_the_record_it_belongs_to_never_by_its_name() { // The same trap the merge itself refuses, one layer up: an UNJOINED row bears a name that // belongs to another module's function, so a view that looked prose up by name would hand it a // sentence about code it is not. let m = mono( json!({ "GetName": { "signature": { "library": "server", "linux": "55" }, "provenance": { "tier": "core", "addr": "0x1" } } }), ); let bindings: Bindings = from(json!({ "meta": { "game_key": "csgo", "source_build": "b", "pulse": 0, "entity_inputs": 0 }, "pulse": {}, "entity_inputs": [], "vscript": [ { "name": "GetName", "cpp": "GetName", "class": "C", "library": "server", "ret_raw": 0 }, { "name": "GetName", "cpp": "GetName", "class": "C", "library": "engine2", "ret_raw": 0 }, ], })); let desc: BTreeMap = from(json!({ "GetName": { "text": "the server one", "source": "generated" } })); let r = merge(m, None, Some(bindings), None, desc); let vs = r.vscript(); let joined = vs.iter().find(|v| v.library == "server").unwrap(); assert_eq!( joined.doc.as_ref().map(|d| d.text.as_str()), Some("the server one") ); let elsewhere = vs.iter().find(|v| v.library == "engine2").unwrap(); assert!(elsewhere.doc.is_none()); // The prototype view has no such hazard — it is keyed by the record — so it carries the prose. let m2 = mono(json!({ "A::k": { "offset": 1, "provenance": { "tier": "core" } } })); let abi: AbiManifest = from(json!({ "meta": { "game_key": "csgo", "source_build": "b", "counts": {} }, "functions": { "A::k": { "tier": "core", "matched_by": "exact", "status": "verified" } }, })); let desc2: BTreeMap = from(json!({ "A::k": { "text": "t", "source": "derived" } })); let man = merge(m2, Some(abi), None, None, desc2).abi_manifest(); assert_eq!( man.functions["A::k"] .doc .as_ref() .map(|d| d.source.as_str()), Some("derived") ); } #[test] fn the_resolved_prose_is_a_view_and_never_reaches_the_artifact() { // `doc` is joined at view time from facts the artifact already states. Serializing it would // give one sentence two homes, and two homes is how they come to disagree. let m = mono(json!({ "A::k": { "offset": 1, "provenance": { "tier": "core" } } })); let abi: AbiManifest = from(json!({ "meta": { "game_key": "csgo", "source_build": "b", "counts": {} }, "functions": { "A::k": { "tier": "core", "matched_by": "exact", "status": "verified" } }, })); let desc: BTreeMap = from(json!({ "A::k": { "text": "t", "source": "generated" } })); let r = merge(m, Some(abi), None, None, desc); let v = serde_json::to_value(&r).unwrap(); assert!(v["functions"]["A::k"]["prototype"].get("doc").is_none()); assert_eq!(v["functions"]["A::k"]["description"]["text"], "t"); // and the view still produces it from what WAS written let back: Rosetta = serde_json::from_value(v).unwrap(); assert_eq!(back.abi_manifest().functions["A::k"].doc.is_some(), true); } #[test] fn every_declared_row_lands_exactly_once() { // The merge must not be a filter: a row either describes a function this build ships, or it is // stated as a surface. Nothing the deriver read may fall out between the two. let m = mono( json!({ "ConCommand::a": { "signature": { "library": "server", "linux": "55" }, "provenance": { "tier": "core" } } }), ); let bindings: Bindings = from(json!({ "meta": { "game_key": "csgo", "source_build": "b", "pulse": 0, "entity_inputs": 0 }, "pulse": {}, "entity_inputs": [{ "input": "Kill", "handler": "nosuchfunction", "library": "server", "addr": "0x1" }], "commands": [ { "name": "a", "library": "server", "flags_raw": "0x0", "form": "direct", "addr": "0x1" }, { "name": "a", "library": "engine2", "flags_raw": "0x0", "form": "direct", "addr": "0x2" }, { "name": "b", "library": "server", "flags_raw": "0x0", "form": "direct", "addr": "0x3" }, ], "vscript": [{ "name": "K", "cpp": "nope", "library": "server", "ret_raw": 0 }], })); let r = merge(m, None, Some(bindings), None, BTreeMap::new()); let (j, u) = (&r.meta.joined.bindings, &r.surfaces.unjoined); assert_eq!((j.entity_input, u.entity_inputs.len()), (0, 1)); assert_eq!((j.command, u.commands.len()), (1, 2)); // one joined; the other library's + the unknown assert_eq!((j.vscript, u.vscript.len()), (0, 1)); } #[test] fn a_vtable_located_record_cannot_contradict_a_library_so_it_keeps_the_binding() { // A slot names no library, so there is nothing to check the row against — and refusing on // absent evidence would drop bindings for every offset-located function. let m = mono(json!({ "A::b": { "offset": 3, "provenance": { "tier": "core" } } })); let bindings: Bindings = from(json!({ "meta": { "game_key": "csgo", "source_build": "b", "pulse": 0, "entity_inputs": 0 }, "pulse": {}, "entity_inputs": [{ "input": "Kill", "class": "A", "handler": "b", "library": "engine2", "addr": "0x1" }], })); let r = merge(m, None, Some(bindings), None, BTreeMap::new()); assert_eq!(r.functions["A::b"].bindings.len(), 1); } #[test] fn a_binding_with_no_function_record_survives_as_a_surface() { let m = mono(json!({ "A::b": { "offset": 3, "provenance": { "tier": "core" } } })); let bindings: Bindings = from(json!({ "meta": { "game_key": "csgo", "source_build": "b", "pulse": 0, "entity_inputs": 0 }, "pulse": {}, "entity_inputs": [], "vscript": [{ "name": "Kill", "cpp": "Script_Kill", "library": "server", "ret_raw": 0 }], })); let r = merge(m, None, Some(bindings), None, BTreeMap::new()); assert_eq!(r.meta.joined.bindings.vscript, 0); assert_eq!(r.surfaces.unjoined.vscript.len(), 1); assert_eq!(r.surfaces.unjoined.vscript[0].cpp, "Script_Kill"); } #[test] fn the_artifact_reads_back_as_what_was_written() { let m = mono(json!({ "A::b": { "signature": { "library": "server", "linux": "55" }, "provenance": { "tier": "core" }, "aliases": ["UTIL::b"] } })); let bindings: Bindings = from(json!({ "meta": { "game_key": "csgo", "source_build": "b", "pulse": 0, "entity_inputs": 0 }, "pulse": {}, "entity_inputs": [{ "input": "Kill", "class": "A", "handler": "b", "library": "server", "addr": "0x1" }], })); let schema: Schema = from(json!({ "meta": { "game_key": "csgo", "source_build": "b", "typed": 1, "untyped": 0 }, "classes": { "A": { "m_i": { "offset": 4, "size": 4, "name_hash": 7 } } }, })); let r = merge(m, None, Some(bindings), Some(schema), BTreeMap::new()); let back: Rosetta = serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap(); assert_eq!(back.functions["A::b"].tier, Section::Core); assert_eq!(back.functions["A::b"].aliases, ["UTIL::b"]); assert!(matches!( back.functions["A::b"].bindings[..], [FunctionBinding::EntityInput { .. }] )); assert_eq!(back.schema.unwrap().classes["A"]["m_i"].offset, 4); } } #[cfg(test)] mod monolith_tests { use super::*; #[test] fn mono_entry_flattens_locator_and_kebabs_tier() { let e = MonoEntry { locator: Entry { signature: None, offset: Some(158), class: None, anchors: Vec::new(), }, abi: None, provenance: Provenance { source: Some("catalogue".into()), ..Provenance::with_tier(Tier::Core) }, validated: Some(true), aliases: Vec::new(), }; let v = serde_json::to_value(&e).unwrap(); assert_eq!(v["offset"], 158); // locator flattened to the top level assert!(v.get("signature").is_none()); // None locator field omitted assert_eq!(v["provenance"]["tier"], "core"); // kebab-case enum assert_eq!(v["provenance"]["source"], "catalogue"); assert!(v["provenance"].get("confidence").is_none()); // None provenance field skipped assert_eq!(v["validated"], true); } #[test] fn experimental_entry_keeps_class_and_serializes_null_validated() { let e = MonoEntry { locator: Entry { signature: None, offset: Some(40), class: Some("CFoo".into()), anchors: Vec::new(), }, abi: None, provenance: Provenance { confidence: Some("low".into()), self_named: Some(false), collision: Some(true), dead_weight: Some(false), ..Provenance::with_tier(Tier::Low) }, validated: None, aliases: Vec::new(), }; let v = serde_json::to_value(&e).unwrap(); assert_eq!(v["class"], "CFoo"); assert_eq!(v["provenance"]["tier"], "low"); assert_eq!(v["provenance"]["collision"], true); assert_eq!(v["validated"], serde_json::Value::Null); // present as null, not omitted } #[test] fn monolith_round_trips() { let mut m = Monolith { meta: MonoMeta { game_key: "csgo".into(), game: "CS2".into(), source_build: "2026-07-15_003539".into(), version: "cs2-12345-0".into(), counts: Counts { core: 1, high_confidence: 0, experimental: 0, unresolved: 1, }, alias_groups: 0, aliased_names: 0, }, core: BTreeMap::new(), high_confidence: BTreeMap::new(), experimental: BTreeMap::new(), unresolved: BTreeMap::new(), }; m.core.insert( "A::b".into(), MonoEntry { locator: Entry { signature: Some(Sig { library: "server".into(), linux: "55 48 89 E5".into(), }), offset: None, class: None, anchors: Vec::new(), }, abi: None, provenance: Provenance { source: Some("catalogue".into()), ..Provenance::with_tier(Tier::Core) }, validated: Some(true), aliases: Vec::new(), }, ); m.unresolved.insert( "C::d".into(), Unresolved { reason: "sig-drifted".into(), detail: "no unique/recovered signature in target".into(), }, ); let s = serde_json::to_string_pretty(&m).unwrap(); let back: Monolith = serde_json::from_str(&s).unwrap(); assert_eq!(back.core.len(), 1); assert_eq!( back.core["A::b"] .locator .signature .as_ref() .unwrap() .library, "server" ); assert_eq!(back.unresolved["C::d"].reason, "sig-drifted"); assert_eq!(back.meta.counts.unresolved, 1); } }