initial commit
This commit is contained in:
commit
a2922b8bad
59 changed files with 2684583 additions and 0 deletions
15
crates/source2rosetta-core/Cargo.toml
Normal file
15
crates/source2rosetta-core/Cargo.toml
Normal 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"] }
|
||||
82
crates/source2rosetta-core/README.md
Normal file
82
crates/source2rosetta-core/README.md
Normal 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).
|
||||
87
crates/source2rosetta-core/src/bin/source2rosetta-gen.rs
Normal file
87
crates/source2rosetta-core/src/bin/source2rosetta-gen.rs
Normal 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(())
|
||||
}
|
||||
7
crates/source2rosetta-core/src/lib.rs
Normal file
7
crates/source2rosetta-core/src/lib.rs
Normal 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;
|
||||
553
crates/source2rosetta-core/src/model.rs
Normal file
553
crates/source2rosetta-core/src/model.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
644
crates/source2rosetta-core/src/render.rs
Normal file
644
crates/source2rosetta-core/src/render.rs
Normal 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(÷r);
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue