source2rosetta/crates/source2rosetta-core/src/model.rs
Kamal Tufekcic a2922b8bad
All checks were successful
CI / fuzz (push) Successful in 1m41s
CI / lint (push) Successful in 16s
CI / test (push) Successful in 22s
initial commit
2026-07-27 10:12:04 +03:00

553 lines
22 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.
#[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)
}
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,
}
}
/// A vtable-offset-only locator.
pub fn offset(linux: i64) -> Entry {
Entry {
signature: None,
offset: Some(linux),
}
}
}
/// 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,
}
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",
}
}
}
/// 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);
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
// ===========================================================================================
// The monolith model — the shipped `gamedata-<game>.json`. Four confidence tiers with provenance
// + live-validation folded inline; `source2rosetta-gen` renders it into any framework format, and it is
// equally readable as-is by a consumer. 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`, `self-named`, `dict-exact`, `contextual`, `corroborated`, `high`, `medium`, `low`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Tier {
Core,
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::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,
"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,
/// experimental offsets only: the vtable class the slot lives on (a reader's eyeball check).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub class: Option<String>,
pub provenance: Provenance,
/// Live-validation verdict: `Some(true)` passed, `Some(false)` dropped confident-bad, `None` unvalidated.
#[serde(default)]
pub validated: Option<bool>,
}
/// 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,
}
/// The full derived gamedata for one build — the shipped `gamedata-<game>.json`. Four confidence tiers, each
/// a key-sorted map. `source2rosetta-gen` renders it into any framework format; a consumer can equally read it
/// directly, gating `experimental` behind a runtime toggle off each entry's tier.
#[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
}
}
/// 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 `netvars-<game>.json` consumers expect.
#[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,
}
/// The typed schema — the shipped `netvars-<game>.json`. Merges field offsets with runtime types:
/// class -> field -> [`Field`].
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct Schema {
pub meta: SchemaMeta,
pub classes: BTreeMap<String, BTreeMap<String, Field>>,
}
/// 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,
}
#[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,
provenance: Provenance {
source: Some("catalogue".into()),
..Provenance::with_tier(Tier::Core)
},
validated: Some(true),
};
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()),
provenance: Provenance {
confidence: Some("low".into()),
self_named: Some(false),
collision: Some(true),
dead_weight: Some(false),
..Provenance::with_tier(Tier::Low)
},
validated: None,
};
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,
},
},
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,
provenance: Provenance {
source: Some("catalogue".into()),
..Provenance::with_tier(Tier::Core)
},
validated: Some(true),
},
);
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);
}
}