initial commit
All checks were successful
CI / fuzz (push) Successful in 1m41s
CI / lint (push) Successful in 16s
CI / test (push) Successful in 22s

This commit is contained in:
Kamal Tufekcic 2026-07-27 10:12:04 +03:00
commit a2922b8bad
59 changed files with 2684583 additions and 0 deletions

View file

@ -0,0 +1,15 @@
[package]
name = "source2rosetta-core"
version = "0.1.0"
edition = "2024"
description = "source2rosetta's deriver-free core: canonical gamedata model + format emitters (serde-only)"
license = "AGPL-3.0-only"
repository = "https://git.lo.sh/kamal/source2rosetta"
readme = "README.md"
authors = ["kamal"]
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
clap = { version = "4", features = ["derive"] }

View file

@ -0,0 +1,82 @@
# source2rosetta-gen
Render a published [source2rosetta](../../README.md) gamedata release into whatever format your framework
reads. `source2rosetta` does the hard part — deriving CS2 / Dota 2 gamedata from the stripped engine and
validating it on a live server — and publishes two JSON files per game. `source2rosetta-gen` turns those into
CounterStrikeSharp, Metamod/SourceMod, ModSharp, Swiftly, Plugify, or a typed C# SDK, locally, in a second.
It's deliberately tiny: it links only `source2rosetta-core` (serde + the format emitters) — **no** ELF reader,
no disassembler, no ptrace. So a consumer who "just wants the files" downloads one release + this small binary
and generates exactly what they need, instead of every format being pre-baked into the release.
## Get it
Grab the prebuilt `source2rosetta-gen` from the release page, or build it from source:
```sh
cargo build --release -p source2rosetta-core
# -> target/release/source2rosetta-gen
```
(The `gen` binary lives in the `source2rosetta-core` crate, so a plain `cargo build --release` at the repo root
does **not** build it — use `-p source2rosetta-core` or `--workspace`.)
## Use it
Download the two artifacts for your game from the release page:
- `gamedata-<game>.json` — the derived gamedata (function signatures + vtable offsets), tiered by confidence.
- `netvars-<game>.json` — the typed schema (every class's field offsets + runtime types).
Then point `gen` at whichever you need and pick a `--format`. Output goes to `--out`, or stdout if omitted.
```sh
# CounterStrikeSharp combined gamedata (the default)
source2rosetta-gen --from gamedata-cs2.json --format cssharp --out gamedata.json
# Metamod / SourceMod gamedata VDF (one .games.txt)
source2rosetta-gen --from gamedata-cs2.json --format metamod --out csgo.games.txt
# Swiftly / ModSharp / Plugify gamedata
source2rosetta-gen --from gamedata-cs2.json --format swiftly --out gamedata.json
# Typed C# SDK from the schema — one `static class` per engine class, `const` field offsets + types
source2rosetta-gen --netvars netvars-cs2.json --format cs-sdk --out Schema.cs
# Flat netvar offset map (class -> field -> offset)
source2rosetta-gen --netvars netvars-cs2.json --format netvars --out netvars.json
```
## Formats
| `--format` | needs | output |
|---|---|---|
| `cssharp` *(default)* | `--from` | CounterStrikeSharp combined gamedata (a commented, sectioned file) |
| `metamod` | `--from` | Metamod:Source / SourceMod gamedata VDF (`.games.txt`) |
| `modsharp` | `--from` | ModSharp gamedata JSON |
| `swiftly` | `--from` | Swiftly gamedata JSON |
| `plugify` | `--from` | Plugify gamedata JSON |
| `model` | `--from` | the canonical model, re-serialized (format-neutral) |
| `cs-sdk` | `--netvars` | typed C# SDK — `static class` per schema class, `const int` field offsets tagged with their type |
| `netvars` | `--netvars` | flat schema map, `{ class: { field: offset } }` |
## Confidence tier
The gamedata formats (the `--from` ones) take a `--tier`, cumulative and defaulting to `high_confidence`:
| `--tier` | includes |
|---|---|
| `core` | only the guaranteed, first-class entries |
| `high_confidence` *(default)* | `core` + the promoted (verified-name) entries |
| `experimental` | the above + every graded name guess (each has a resolvable locator, but an **unverified** name) |
```sh
# only the rock-solid set:
source2rosetta-gen --from gamedata-cs2.json --format cssharp --tier core --out gamedata.json
```
The schema formats (`cs-sdk`, `netvars`) ignore `--tier`.
---
Part of [source2rosetta](../../README.md) · [AGPL-3.0](../../LICENSE).

View file

@ -0,0 +1,87 @@
//! `source2rosetta-gen` — the standalone generator. Reads the published monolith (`gamedata-<game>.json`
//! from `source2rosetta produce`) and renders it into any framework's gamedata format at a chosen confidence
//! tier.
//!
//! It touches only the `model` + `render` layers — no ELF reader, no ptrace, no disassembler — so a consumer
//! who "just wants the files" downloads one monolith + this small, rarely-changing binary and generates
//! whatever their framework needs locally, instead of every format being pre-baked into releases. It lives in
//! the `source2rosetta-core` crate (serde-only), so it stays genuinely lean.
use anyhow::{Context, Result, bail};
use clap::Parser;
use source2rosetta_core::{model, render};
use std::path::PathBuf;
#[derive(Parser)]
#[command(
name = "source2rosetta-gen",
about = "Render a source2rosetta monolith into a framework gamedata format"
)]
struct Cli {
/// The monolith `gamedata-<game>.json` (for a GAMEDATA --format: cssharp/metamod/modsharp/swiftly/plugify/model).
#[arg(long)]
from: Option<PathBuf>,
/// The typed `netvars-<game>.json` (for a SCHEMA --format: cs-sdk/netvars).
#[arg(long)]
netvars: Option<PathBuf>,
/// Output format. GAMEDATA (needs --from): cssharp | metamod | modsharp | swiftly | plugify | model.
/// SCHEMA (needs --netvars): cs-sdk (typed C# SDK) | netvars (flat offset map). cssharp = the
/// `//`-bannered CS# combined file; metamod also covers SourceMod (the VDF `.games.txt`).
#[arg(long, default_value = "cssharp")]
format: String,
/// Confidence tier for a gamedata format (cumulative): core | high_confidence | experimental. Defaults to
/// `high_confidence` (core + the promoted names). Ignored by schema formats.
#[arg(long, default_value = "high_confidence")]
tier: String,
/// Write here (default: stdout).
#[arg(long)]
out: Option<PathBuf>,
}
fn main() -> Result<()> {
let cli = Cli::parse();
let fmt = cli.format.as_str();
let text = if render::SCHEMA_FORMAT_IDS.contains(&fmt) {
// schema formats render the typed netvars (class -> field -> offset/type), not the gamedata monolith.
let path = cli.netvars.as_ref().context(
"a schema --format (cs-sdk | netvars) requires --netvars <netvars-<game>.json>",
)?;
let schema: model::Schema = serde_json::from_str(&std::fs::read_to_string(path)?)
.with_context(|| format!("parse netvars json {}", path.display()))?;
render::schema_by_id(fmt)
.expect("known schema format")
.render(&schema)
} else {
let path = cli
.from
.as_ref()
.context("a gamedata --format requires --from <gamedata-<game>.json>")?;
let tier = model::TierSelect::from_id(&cli.tier).with_context(|| {
format!(
"unknown --tier {:?} (want one of: {})",
cli.tier,
model::TIER_IDS.join(" | ")
)
})?;
let mono: model::Monolith = serde_json::from_str(&std::fs::read_to_string(path)?)
.with_context(|| format!("parse monolith json {}", path.display()))?;
match fmt {
// cssharp is the bannered combined file (guaranteed + extrapolated sections), not a flat map.
"cssharp" => render::render_monolith_cssharp(&mono, tier),
f @ ("metamod" | "modsharp" | "swiftly" | "plugify" | "model") => render::by_id(f)
.expect("known flat format")
.render(&mono.select(tier)),
other => bail!(
"unknown --format {other:?} (gamedata: cssharp|metamod|modsharp|swiftly|plugify|model; \
schema: cs-sdk|netvars)"
),
}
};
match cli.out {
Some(p) => std::fs::write(&p, text).with_context(|| format!("write {}", p.display()))?,
None => println!("{text}"),
}
Ok(())
}

View file

@ -0,0 +1,7 @@
//! source2rosetta-core — the deriver-free core: the canonical gamedata [`model`] + the format [`render`]
//! emitters. Depends only on serde (+ clap/anyhow for the `source2rosetta-gen` binary), NOT on the ELF
//! reader / disassembler / ptrace layers. Both the `source2rosetta` deriver and the standalone
//! `source2rosetta-gen` link this, so the generator carries none of the derivation weight.
pub mod model;
pub mod render;

View file

@ -0,0 +1,553 @@
//! 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);
}
}

View file

@ -0,0 +1,644 @@
//! Emitters: render the canonical [`Gamedata`] model into each framework's on-disk format. Adding a
//! new consumer (SourceMod, a diff, a language SDK) is a new `impl GamedataEmitter` here — the
//! derivation never changes. This module depends only on `model` + serde, NOT on the deriver, so a
//! standalone `source2rosetta-gen` binary can link just this to turn a published model JSON into files.
use crate::model::{Entry, Gamedata, MonoEntry, Monolith, Schema, Tier, TierSelect};
use serde_json::{Map, Value, json};
/// Render the monolith as the CounterStrikeSharp combined `gamedata.json`: the guaranteed `core` section, a
/// `//` banner, then the extrapolated section (the tiers `select` includes beyond core), both key-sorted with
/// compact locators. At `TierSelect::HighConfidence` the output is exactly core + high_confidence; other
/// selections extend the extrapolated section.
///
/// Drops `validated == Some(false)` entries, matching [`Monolith::select`]: an entry live validation
/// confidently rejected must not reach a consumer under a banner claiming it resolves on a running server.
/// (Pre-validation every `validated` is `None`, so there this is a no-op.)
pub fn render_monolith_cssharp(mono: &Monolith, select: TierSelect) -> String {
// the extrapolated section: the tiers beyond core that `select` includes, merged key-sorted. The tiers are
// documented disjoint and are so for anything this crate derives, but an externally-supplied monolith need
// not be — so keep the more-confident tier on collision (`select`'s rule) and subtract `core`, which would
// otherwise emit the same JSON key in both sections.
let mut extra: std::collections::BTreeMap<&String, &MonoEntry> =
std::collections::BTreeMap::new();
let mut sources: Vec<&std::collections::BTreeMap<String, MonoEntry>> = Vec::new();
if select != TierSelect::Core {
sources.push(&mono.high_confidence);
}
if select == TierSelect::Experimental {
sources.push(&mono.experimental);
}
for m in sources {
for (name, e) in m {
if e.validated == Some(false) || mono.core.contains_key(name) {
continue;
}
extra.entry(name).or_insert(e);
}
}
let n_self = extra
.values()
.filter(|e| e.provenance.tier == Tier::SelfNamed)
.count();
// BOTH labels count: dictionary hits are tagged `DictExact` in the high-confidence tier and
// `Corroborated` in the experimental tier, so counting only one variant misses genuinely corroborated
// entries when the experimental tier is selected.
let n_dict = extra
.values()
.filter(|e| e.provenance.tier.is_dict_corroborated())
.count();
let n_byval = extra
.values()
.filter(|e| e.provenance.by_value == Some(true))
.count();
let line = |name: &str, e: &MonoEntry| {
format!(
" {}: {}",
serde_json::to_string(name).unwrap_or_default(),
serde_json::to_string(&locator_value(&e.locator)).unwrap_or_default()
)
};
let core: Vec<(&String, &MonoEntry)> = mono
.core
.iter()
.filter(|(_, e)| e.validated != Some(false))
.collect();
let core_lines: Vec<String> = core.iter().map(|(n, e)| line(n, e)).collect();
let extra_lines: Vec<String> = extra.iter().map(|(n, e)| line(n, e)).collect();
let total = core.len() + extra.len();
let header = format!(
"// ============================================================================\n\
// source2rosetta combined gamedata ({version})\n\
// {} guaranteed + {} extrapolated = {total} functions.\n\
// Auto-derived from the stripped {} server libraries, live-validated against a running server.\n\
// (JSON with // comments — CS#'s loader and source2rosetta's own reader both tolerate them.)\n\
// ============================================================================",
core.len(),
extra.len(),
mono.meta.game,
version = mono.meta.version,
);
let divider = [
" // ==========================================================================".to_string(),
format!(" // EXTRAPOLATED NAMES ({} total: {n_self} self-named, {n_dict} dict-corroborated; {n_byval} by-value)", extra.len()),
" // ---------------------------------------------------------------------------".to_string(),
" // Everything BELOW is AI-extrapolated from the stripped binary. Each LOCATOR".to_string(),
" // (sig / vtable offset) is live-validated — it resolves to real executable code".to_string(),
" // on a running server. The NAME is a best-effort label, not a symbol Valve".to_string(),
" // shipped (the binary is stripped); the exact C++ prototype (arg/return types)".to_string(),
" // is unrecoverable, so confirm the call signature yourself. Trust tiers + a".to_string(),
" // by-value (unsafe-to-naive-call) flag live in the provenance sidecar. Above = guaranteed.".to_string(),
" // ==========================================================================".to_string(),
]
.join("\n");
let mut s = header;
s.push_str("\n{\n");
s.push_str(&core_lines.join(",\n"));
if !core_lines.is_empty() && !extra_lines.is_empty() {
s.push(','); // the last core entry needs a comma — the extrapolated section follows the banner
}
s.push('\n');
s.push_str(&divider);
s.push('\n');
s.push_str(&extra_lines.join(",\n"));
s.push_str("\n}\n");
s
}
/// Render a derived gamedata model to one output format's text.
pub trait GamedataEmitter {
fn id(&self) -> &'static str;
fn render(&self, gd: &Gamedata) -> String;
}
/// The canonical CS#-gamedata locator for one entry: an object carrying `"signatures":{library,linux}`
/// when it has a signature and/or `"offsets":{linux}` when it has a vtable slot. This is the on-disk shape
/// the cssharp `gamedata.json` and the deriver's combined/experimental files all share, so every producer
/// builds it through this ONE function instead of an ad-hoc `json!`. Keys serialize sorted (serde_json's
/// default `Map`), so an entry carrying both a signature and an offset is deterministic.
pub fn locator_value(e: &Entry) -> Value {
let mut obj = Map::new();
if let Some(s) = &e.signature {
obj.insert(
"signatures".into(),
json!({ "library": s.library, "linux": s.linux }),
);
}
if let Some(o) = e.offset {
obj.insert("offsets".into(), json!({ "linux": o }));
}
Value::Object(obj)
}
/// The inverse of [`locator_value`]: parse the on-disk cssharp locator shape
/// (`{"signatures":{library,linux}}` and/or `{"offsets":{linux}}`) back into a typed [`Entry`]. Lives here,
/// beside the forward writer, so the round-trip is single-sourced in `core` instead of hand-rolled in the
/// deriver. Tolerant: a missing `signatures.library` defaults to `server`, a
/// missing `signatures.linux` to empty, and an entry may carry a signature and/or an offset (or neither).
pub fn entry_from_value(v: &Value) -> Entry {
let signature = v.get("signatures").map(|s| crate::model::Sig {
library: s
.get("library")
.and_then(Value::as_str)
.unwrap_or("server")
.to_string(),
linux: s
.get("linux")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
});
let offset = v
.get("offsets")
.and_then(|o| o.get("linux"))
.and_then(Value::as_i64);
Entry { signature, offset }
}
/// Look up an emitter by its `--format` id.
pub fn by_id(id: &str) -> Option<Box<dyn GamedataEmitter>> {
match id {
"cssharp" => Some(Box::new(CsSharp)),
"metamod" => Some(Box::new(Metamod)),
"modsharp" => Some(Box::new(ModSharp)),
"swiftly" => Some(Box::new(Swiftly)),
"plugify" => Some(Box::new(Plugify)),
"model" => Some(Box::new(ModelJson)),
_ => None,
}
}
/// Every `--format` id `by_id` accepts — for help text and error messages (keep in sync with `by_id`).
pub const FORMAT_IDS: &[&str] = &[
"cssharp", "metamod", "modsharp", "swiftly", "plugify", "model",
];
/// The canonical model itself, serialized — the format-neutral artifact `source2rosetta-gen` reads back.
pub struct ModelJson;
impl GamedataEmitter for ModelJson {
fn id(&self) -> &'static str {
"model"
}
fn render(&self, gd: &Gamedata) -> String {
serde_json::to_string_pretty(gd).unwrap_or_default()
}
}
/// CounterStrikeSharp `gamedata.json`: `{ name: { "signatures": {library,linux} | "offsets": {linux} } }`.
/// Built through serde_json's key-sorted Map for deterministic output.
pub struct CsSharp;
impl GamedataEmitter for CsSharp {
fn id(&self) -> &'static str {
"cssharp"
}
fn render(&self, gd: &Gamedata) -> String {
let mut doc = Map::new();
for (name, e) in &gd.entries {
doc.insert(name.clone(), locator_value(e));
}
serde_json::to_string_pretty(&Value::Object(doc)).unwrap_or_default()
}
}
/// Metamod/SourceMod `*.games.txt` (Valve KeyValues): `Games { csgo { Signatures{..} Offsets{..} } }`.
/// Signature bytes become `\xAB` escapes with `\x2A` for wildcards.
pub struct Metamod;
/// "55 48 ? E5" -> "\x55\x48\x2A\xE5". Handles every wildcard token `sig::Pattern` accepts (`?`/`??`/`*`).
///
/// LIMITATION: SourceMod/Metamod's wildcard byte IS 0x2A (`*`), so a signature with a *fixed* 0x2A
/// byte is inherently ambiguous in this format — it renders as `\x2A` and Metamod reads it as a
/// wildcard, widening the pattern. That's a constraint of the VDF signature format itself (real
/// SourceMod gamedata shares it), not something the emitter can encode away; a fully Metamod-safe
/// pattern would need make_sig to avoid depending on a fixed 0x2A byte for uniqueness.
fn vdf_pattern(spacehex: &str) -> String {
spacehex
.split_whitespace()
.map(|t| match t {
"?" | "??" | "*" => "\\x2A".to_string(),
b => format!("\\x{}", b.to_uppercase()),
})
.collect()
}
impl GamedataEmitter for Metamod {
fn id(&self) -> &'static str {
"metamod"
}
fn render(&self, gd: &Gamedata) -> String {
let mut sigs = String::new();
let mut offs = String::new();
for (name, e) in &gd.entries {
if let Some(s) = &e.signature {
sigs.push_str(&format!(
"\t\t\t\"{name}\"\n\t\t\t{{\n\t\t\t\t\"library\"\t\"{}\"\n\t\t\t\t\"linux\"\t\"{}\"\n\t\t\t}}\n",
s.library,
vdf_pattern(&s.linux),
));
}
if let Some(o) = e.offset {
offs.push_str(&format!(
"\t\t\t\"{name}\"\n\t\t\t{{\n\t\t\t\t\"linux\"\t\"{o}\"\n\t\t\t}}\n"
));
}
}
let key = &gd.game_key;
format!(
"\"Games\"\n{{\n\t\"{key}\"\n\t{{\n\t\t\"Signatures\"\n\t\t{{\n{sigs}\t\t}}\n\t\t\"Offsets\"\n\t\t{{\n{offs}\t\t}}\n\t}}\n}}\n"
)
}
}
/// ModSharp `*.games.jsonc`: `{ "Addresses": {sig}, "VFuncs": {offset} }`. Signatures carry a `library`;
/// VFuncs are a bare `linux` slot index. Linux-only (windows isn't derived from a `.so`).
pub struct ModSharp;
impl GamedataEmitter for ModSharp {
fn id(&self) -> &'static str {
"modsharp"
}
fn render(&self, gd: &Gamedata) -> String {
let (mut addresses, mut vfuncs) = (Map::new(), Map::new());
for (name, e) in &gd.entries {
if let Some(s) = &e.signature {
addresses.insert(
name.clone(),
json!({ "library": s.library, "linux": s.linux }),
);
}
if let Some(o) = e.offset {
vfuncs.insert(name.clone(), json!({ "linux": o }));
}
}
let doc = json!({ "Addresses": addresses, "VFuncs": vfuncs });
serde_json::to_string_pretty(&doc).unwrap_or_default()
}
}
/// SwiftlyS2 `signatures.jsonc`: `{ name: {"lib": lib, "linux": sig} }`. Swiftly keeps offsets in a
/// separate `offsets.jsonc`; this emits the signatures file (the bulk of a gamedata).
pub struct Swiftly;
impl GamedataEmitter for Swiftly {
fn id(&self) -> &'static str {
"swiftly"
}
fn render(&self, gd: &Gamedata) -> String {
let mut doc = Map::new();
for (name, e) in &gd.entries {
if let Some(s) = &e.signature {
doc.insert(name.clone(), json!({ "lib": s.library, "linux": s.linux }));
}
}
serde_json::to_string_pretty(&Value::Object(doc)).unwrap_or_default()
}
}
/// Plugify (s2sdk) `gamedata.jsonc`: `{ "csgo": { "Signatures": {..}, "Offsets": {..} } }`. Uses the
/// `linuxsteamrt64` platform key; signatures carry a `library`.
pub struct Plugify;
impl GamedataEmitter for Plugify {
fn id(&self) -> &'static str {
"plugify"
}
fn render(&self, gd: &Gamedata) -> String {
let (mut sigs, mut offs) = (Map::new(), Map::new());
for (name, e) in &gd.entries {
if let Some(s) = &e.signature {
sigs.insert(
name.clone(),
json!({ "library": s.library, "linuxsteamrt64": s.linux }),
);
}
if let Some(o) = e.offset {
offs.insert(name.clone(), json!({ "linuxsteamrt64": o }));
}
}
let mut doc = Map::new();
doc.insert(
gd.game_key.clone(),
json!({ "Signatures": sigs, "Offsets": offs }),
);
serde_json::to_string_pretty(&Value::Object(doc)).unwrap_or_default()
}
}
// ============================ SCHEMA / NETVAR emitters ============================
// The typed schema (`netvars-<game>.json`, `model::Schema`) has its own emitter family, parallel to
// `GamedataEmitter`: turn the class->field->offset/type surface into a consumable SDK or netvar file.
// Adding a language SDK or a framework netvar format is a new `impl SchemaEmitter` here.
/// Render `model::Schema` (the typed netvars) into a consumer format (a language SDK, a netvar map).
pub trait SchemaEmitter {
fn id(&self) -> &'static str;
fn render(&self, schema: &Schema) -> String;
}
/// A Source-2 schema type as its C# spelling: the primitive scalars map to C# built-ins; everything else
/// (Source-2 math types, handles, `CUtl*`, enums, templates) is kept verbatim as the ground-truth type name
/// — a fully-typed SDK for all 1,400+ custom types isn't ours to define, and the raw name is the honest hint.
fn cs_type(ty: &str) -> &str {
match ty {
"int8" | "char8" => "sbyte",
"uint8" => "byte",
"int16" => "short",
"uint16" => "ushort",
"int32" => "int",
"uint32" => "uint",
"int64" => "long",
"uint64" => "ulong",
"float32" => "float",
"float64" => "double",
"bool" => "bool",
other => other,
}
}
/// A schema name (class or nested-type) as a valid C# identifier: nested `Outer::Inner` and any other
/// non-identifier char collapse to `_`. The original name rides along in an XML-doc comment when it changed.
fn cs_ident(name: &str) -> String {
let id: String = name
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' {
c
} else {
'_'
}
})
.collect();
if id.chars().next().is_some_and(|c| c.is_ascii_digit()) {
format!("_{id}")
} else {
id
}
}
/// A typed C# SDK: one `static class` per schema class, each field a `const int <name> = 0x<offset>;` tagged
/// with its C# / Source-2 type. Framework-neutral (offsets + types, no runtime-read assumption), complete
/// (every field, every type), deterministic (the schema's BTreeMaps sort classes then fields).
pub struct CsSdk;
impl SchemaEmitter for CsSdk {
fn id(&self) -> &'static str {
"cs-sdk"
}
fn render(&self, s: &Schema) -> String {
let m = &s.meta;
let mut out = String::new();
out.push_str(&format!(
"// <auto-generated> source2rosetta — {} build {}. Source-2 SchemaSystem field offsets.\n\
// {} classes, {} typed fields. Regenerate: source2rosetta-gen --netvars netvars-{}.json --format cs-sdk\n\
namespace Source2.Schema;\n",
m.game_key, m.source_build, s.classes.len(), m.typed, m.game_key
));
for (cls, fields) in &s.classes {
let ident = cs_ident(cls);
out.push('\n');
if ident != *cls {
out.push_str(&format!(
"/// <summary><c>{cls}</c> — {} fields</summary>\n",
fields.len()
));
} else {
out.push_str(&format!(
"/// <summary>{cls} — {} fields</summary>\n",
fields.len()
));
}
out.push_str(&format!("public static class {ident}\n{{\n"));
for (fname, f) in fields {
out.push_str(&format!(
" public const int {fname} = 0x{:X}; // {}\n",
f.offset,
cs_type(&f.ty)
));
}
out.push_str("}\n");
}
out
}
}
/// Framework-neutral netvar map: `{ class: { field: offset } }` — the raw offset table any tool/framework
/// can consume without the SDK's C# packaging.
pub struct NetvarsJson;
impl SchemaEmitter for NetvarsJson {
fn id(&self) -> &'static str {
"netvars"
}
fn render(&self, s: &Schema) -> String {
let mut doc = Map::new();
for (cls, fields) in &s.classes {
let mut fm = Map::new();
for (fname, f) in fields {
fm.insert(fname.clone(), json!(f.offset));
}
doc.insert(cls.clone(), Value::Object(fm));
}
serde_json::to_string_pretty(&Value::Object(doc)).unwrap_or_default()
}
}
/// Look up a schema emitter by its `--format` id.
pub fn schema_by_id(id: &str) -> Option<Box<dyn SchemaEmitter>> {
match id {
"cs-sdk" => Some(Box::new(CsSdk)),
"netvars" => Some(Box::new(NetvarsJson)),
_ => None,
}
}
/// Every schema `--format` id `schema_by_id` accepts (keep in sync with it).
pub const SCHEMA_FORMAT_IDS: &[&str] = &["cs-sdk", "netvars"];
#[cfg(test)]
mod tests {
use super::*;
use crate::model::Gamedata;
fn sample() -> Gamedata {
let mut gd = Gamedata::default();
gd.set_signature("Host_Say", "server", "55 48 89 ? E5");
gd.set_offset("GameEntitySystem", 80);
gd
}
#[test]
fn cssharp_shape_is_sorted_and_correct() {
let out = CsSharp.render(&sample());
// key-sorted: GameEntitySystem before Host_Say; offsets/signatures shapes intact.
let g = out.find("GameEntitySystem").unwrap();
let h = out.find("Host_Say").unwrap();
assert!(g < h, "entries must be key-sorted");
assert!(out.contains("\"offsets\""));
assert!(out.contains("\"library\": \"server\""));
assert!(out.contains("\"linux\": \"55 48 89 ? E5\""));
}
#[test]
fn locator_value_and_entry_from_value_round_trip() {
use crate::model::{Entry, Sig};
// the canonical write/read pair is a true inverse for both locator kinds
let sig = Entry {
signature: Some(Sig {
library: "engine2".into(),
linux: "55 48 ? E5".into(),
}),
offset: None,
};
let off = Entry {
signature: None,
offset: Some(158),
};
assert_eq!(entry_from_value(&locator_value(&sig)), sig);
assert_eq!(entry_from_value(&locator_value(&off)), off);
// a signature missing its library reads back as "server" — the reader's tolerance
let v = json!({ "signatures": { "linux": "90" } });
assert_eq!(entry_from_value(&v).signature.unwrap().library, "server");
}
#[test]
fn metamod_escapes_bytes_and_wildcards() {
let out = Metamod.render(&sample());
assert!(out.contains("\"Games\""));
assert!(out.contains(r"\x55\x48\x89\x2A\xE5")); // ? -> \x2A
assert!(out.contains("\"linux\"\t\"80\""));
}
#[test]
fn modsharp_splits_addresses_and_vfuncs() {
let v: serde_json::Value = serde_json::from_str(&ModSharp.render(&sample())).unwrap();
assert_eq!(v["Addresses"]["Host_Say"]["library"], "server");
assert_eq!(v["Addresses"]["Host_Say"]["linux"], "55 48 89 ? E5");
assert_eq!(v["VFuncs"]["GameEntitySystem"]["linux"], 80);
assert!(v["Addresses"].get("GameEntitySystem").is_none()); // offset isn't an address
}
#[test]
fn swiftly_signatures_use_lib_key() {
let v: serde_json::Value = serde_json::from_str(&Swiftly.render(&sample())).unwrap();
assert_eq!(v["Host_Say"]["lib"], "server");
assert_eq!(v["Host_Say"]["linux"], "55 48 89 ? E5");
assert!(v.get("GameEntitySystem").is_none()); // offsets are a separate file
}
#[test]
fn plugify_is_game_keyed_with_linuxsteamrt64() {
let v: serde_json::Value = serde_json::from_str(&Plugify.render(&sample())).unwrap();
assert_eq!(v["csgo"]["Signatures"]["Host_Say"]["library"], "server");
assert_eq!(
v["csgo"]["Signatures"]["Host_Say"]["linuxsteamrt64"],
"55 48 89 ? E5"
);
assert_eq!(
v["csgo"]["Offsets"]["GameEntitySystem"]["linuxsteamrt64"],
80
);
}
#[test]
fn game_keyed_emitters_honor_a_non_csgo_game_key() {
// The game-keyed formats (Metamod, Plugify) must wrap output in the model's game_key, not a
// hardcoded "csgo" — the multi-game seam. A dota-keyed model renders under "dota".
let mut gd = sample();
gd.game_key = "dota".to_string();
let mm = Metamod.render(&gd);
assert!(
mm.contains("\t\"dota\"\n") && !mm.contains("\"csgo\""),
"metamod: {mm}"
);
let pl: Value = serde_json::from_str(&Plugify.render(&gd)).unwrap();
assert_eq!(
pl["dota"]["Offsets"]["GameEntitySystem"]["linuxsteamrt64"],
80
);
assert!(pl.get("csgo").is_none());
}
#[test]
fn every_format_id_resolves_and_renders() {
for id in FORMAT_IDS {
let em = by_id(id).unwrap_or_else(|| panic!("by_id({id}) is None"));
assert!(!em.render(&sample()).is_empty(), "{id} rendered empty");
}
}
#[test]
fn metamod_handles_all_wildcard_token_forms() {
// sig::Pattern accepts ?, ?? and * as wildcards — all must render as the VDF wildcard \x2A,
// never as a corrupt \x?? / \x* escape.
let mut gd = Gamedata::default();
gd.set_signature("F", "server", "48 ?? 89 * E5 ?");
let out = Metamod.render(&gd);
assert!(out.contains(r"\x48\x2A\x89\x2A\xE5\x2A"), "got: {out}");
assert!(!out.contains(r"\x??") && !out.contains(r"\x*"));
}
fn sample_schema() -> Schema {
use crate::model::{Field, SchemaMeta};
use std::collections::BTreeMap;
let f = |offset, ty: &str| Field {
offset,
ty: ty.into(),
kind: crate::model::FieldKind::Ref,
size: 4,
name_hash: 0,
};
let mut base = BTreeMap::new();
base.insert("m_iHealth".to_string(), f(0x5B0, "int32"));
let mut nested = BTreeMap::new();
nested.insert("m_x".to_string(), f(0, "float32"));
let mut classes = BTreeMap::new();
classes.insert("CBaseEntity".to_string(), base);
classes.insert("Outer_t::Inner_t".to_string(), nested); // must sanitize to a valid C# identifier
Schema {
meta: SchemaMeta {
game_key: "csgo".into(),
source_build: "b".into(),
typed: 2,
untyped: 0,
},
classes,
}
}
#[test]
fn every_schema_format_renders_nonempty() {
let s = sample_schema();
for id in SCHEMA_FORMAT_IDS {
let em = schema_by_id(id).unwrap_or_else(|| panic!("schema_by_id({id}) is None"));
assert!(!em.render(&s).is_empty(), "{id} rendered empty");
}
}
#[test]
fn cs_sdk_sanitizes_idents_and_maps_primitives() {
let cs = CsSdk.render(&sample_schema());
// primitive types map to C# built-ins; the offset is hex
assert!(
cs.contains("public const int m_iHealth = 0x5B0; // int"),
"{cs}"
);
// a `::` nested class name becomes a valid C# identifier, original kept in the doc comment
assert!(cs.contains("public static class Outer_t__Inner_t"), "{cs}");
assert!(cs.contains("<c>Outer_t::Inner_t</c>"), "{cs}");
// no raw `::` ever leaks into an emitted identifier
for line in cs.lines().filter(|l| l.starts_with("public static class ")) {
assert!(!line.contains("::"), "invalid C# class ident: {line}");
}
}
}