source2rosetta/crates/source2rosetta-core/src/model.rs
Kamal Tufekcic 3410a79b6a
Some checks failed
CI / fuzz (push) Successful in 2m2s
CI / lint (push) Successful in 15s
CI / test (push) Failing after 18s
ship one record per function: merge the release set, gen reads it, descriptions as doc comments, gates for what was only claimed; v3.0
2026-08-02 22:01:36 +03:00

2368 lines
113 KiB
Rust

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