read what the binary says about itself: names, signatures, prototypes; gen v2
All checks were successful
CI / lint (push) Successful in 17s
CI / fuzz (push) Successful in 1m52s
CI / test (push) Successful in 24s

This commit is contained in:
Kamal Tufekcic 2026-07-29 20:09:21 +03:00
commit c458b4cb50
34 changed files with 58363 additions and 192 deletions

View file

@ -1,6 +1,6 @@
[package]
name = "source2rosetta-core"
version = "0.1.0"
version = "2.0.0"
edition = "2024"
description = "source2rosetta's deriver-free core: canonical gamedata model + format emitters (serde-only)"
license = "AGPL-3.0-only"

View file

@ -2,7 +2,7 @@
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
validating it on a live server — and publishes a small set of 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,
@ -23,10 +23,19 @@ does **not** build it — use `-p source2rosetta-core` or `--workspace`.)
## Use it
Download the two artifacts for your game from the release page:
Three of the published artifacts are `gen` inputs, one per `--` flag:
- `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).
- `gamedata-<game>.json` (`--from`) — the derived gamedata (function signatures + vtable offsets), tiered by confidence.
- `netvars-<game>.json` (`--netvars`) — the typed schema (field offsets + runtime types, plus the class base
graph and per-type sizes).
- `abi-<game>.json` (`--abi`) — declared parameter and return types, each re-judged against the footprint
measured in that build. This is what a function TAKES, as opposed to where it is. See
[Call shapes](#call-shapes----abi-abi-gamejson).
`bindings-<game>.json` ships beside them and `gen` does **not** render it — it is not locator data. It is
what the binary declares about itself, in five sections: Pulse bindings (display name, description, call
policy, and each binding's typed signature), entity-IO inputs and outputs, map-classname → C++ class, and
console commands. Plain JSON, readable as-is.
Then point `gen` at whichever you need and pick a `--format`. Output goes to `--out`, or stdout if omitted.
@ -51,15 +60,69 @@ source2rosetta-gen --netvars netvars-cs2.json --format netvars --out netvars.jso
| `--format` | needs | output |
|---|---|---|
| `cssharp` *(default)* | `--from` | CounterStrikeSharp combined gamedata (a commented, sectioned file) |
| `cssharp` *(default)* | `--from` | CounterStrikeSharp combined gamedata **JSONC**: banner comments mean a strict JSON parser will reject it |
| `metamod` | `--from` | Metamod:Source / SourceMod gamedata VDF (`.games.txt`) |
| `modsharp` | `--from` | ModSharp gamedata JSON |
| `swiftly` | `--from` | Swiftly gamedata JSON |
| `swiftly` | `--from` | Swiftly gamedata JSON **signature entries only**; vtable-offset entries are omitted, because that framework takes offsets through a separate file |
| `plugify` | `--from` | Plugify gamedata JSON |
| `model` | `--from` | the canonical model, re-serialized (format-neutral) |
| `model` | `--from` | the selected tiers flattened to one name → locator map (format-neutral; not a re-readable monolith) |
| `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 } }` |
### Call shapes — `--abi abi-<game>.json`
The same framework ids, a different input: `--abi` renders **how to call** a function rather than where it
is. The input picks the family, so `--abi … --format cssharp` emits typed call sites while
`--from … --format cssharp` emits the gamedata those calls resolve through.
All five framework ids work here, exactly as they do for `--from`:
```sh
source2rosetta-gen --abi abi-cs2.json --format cssharp --out RosettaFunctions.cs
source2rosetta-gen --abi abi-cs2.json --format metamod --out rosetta_prototypes.h
source2rosetta-gen --abi abi-cs2.json --format modsharp --out RosettaCalls.cs
source2rosetta-gen --abi abi-cs2.json --format swiftly --out prototypes.json
source2rosetta-gen --abi abi-cs2.json --format plugify --out prototypes.json
```
| `--format` | output |
|---|---|
| `cssharp` | C# `MemoryFunction*` fields (signature) / `VirtualFunction*` factories (vtable slot) |
| `metamod` | C++ header of `using X_t = RET (*)(…)`, plus an `X_vtidx` constant for a slot — Metamod plugins are C++ and take the **declared** types verbatim |
| `modsharp` | C# `[AddressKey]` interface for its Roslyn generator (signature) + a vtable-dispatch class (slot) |
| `swiftly` | JSON per-function type descriptors (`{"args":"ppf","ret":"v","call":"address"}`) |
| `plugify` | JSON runtime type arrays (`{"paramTypes":["pointer","string","float"],"retType":"void"}`) |
Source for the two C# targets and for C++ because their type lists are **compile-time**; data for Swiftly
and Plugify because theirs are resolved at runtime.
**The two locator forms are not interchangeable, and every output distinguishes them.** A signature
resolves to one address; a vtable slot is entered through the object, so the framework reaches it by a
different call entirely — `VirtualFunctionVoid(instance, slot)` rather than `GameData.GetSignature(key)`,
`GetVFuncIndex` rather than `GetAddress`, `(*(void***)self)[idx]` rather than a scanned pointer. Roughly
a quarter of a LIVE-derived manifest's call sites are vtable-located, so binding them all through the
signature path would look up keys that live in the gamedata's `offsets` section and never in its
`signatures` one.
An **offline**-derived manifest emits none through the vtable path at all: a slot is recorded only once
live validation has confirmed it is really a vtable slot and not a carried member offset, so an offline
run states no slot rather than guess one. Same artifact shape, fewer vtable call sites — worth knowing
before diffing two manifests produced different ways.
**The receiver is always in the type list.** Where the declaration came from an Itanium-mangled symbol
`this` is invisible, so it is prepended, spelled from the function's own class (`CBaseEntity*`, not
`void*`) and marked `[this]` in the C++ header. It is a real register in the call frame — leaving it out
shifts every argument by one.
Only functions the deriver could stand behind are emitted: `status: verified` **or `lower-bound`** (the
declaration passes registers the callee never reads and contradicts it in none — safe to call, and
marked as such in every output), a receiver settled by evidence (the declaration names it, a
live-validated vtable slot proves it, or the measurement independently agrees), and every parameter
mappable onto an ABI class. A function whose return **nobody
declared** is still emitted — otherwise Dota would lose 2,304 of its 3,732 call sites — but it is marked as such in every
output (prose in the generated source, `ret_declared` / `retDeclared` in the data), and the value is
documented as the raw return register rather than a typed result.
## Confidence tier
The gamedata formats (the `--from` ones) take a `--tier`, cumulative and defaulting to `high_confidence`:

View file

@ -15,6 +15,7 @@ use std::path::PathBuf;
#[derive(Parser)]
#[command(
name = "source2rosetta-gen",
version,
about = "Render a source2rosetta monolith into a framework gamedata format"
)]
struct Cli {
@ -24,9 +25,17 @@ struct Cli {
/// The typed `netvars-<game>.json` (for a SCHEMA --format: cs-sdk/netvars).
#[arg(long)]
netvars: Option<PathBuf>,
/// The prototype manifest `abi-<game>.json` — renders CALL SHAPES (declared parameter and return
/// types, verified against the build) instead of locators. Reuses the framework --format ids: the
/// INPUT chooses what is rendered, so `--abi … --format cssharp` emits typed call sites while
/// `--from … --format cssharp` emits the gamedata those calls resolve through.
#[arg(long)]
abi: 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`).
/// SCHEMA (needs --netvars): cs-sdk (typed C# SDK) | netvars (flat offset map). ABI (needs --abi):
/// cssharp | metamod | modsharp | swiftly | plugify. cssharp = the `//`-bannered CS# combined file;
/// the metamod gamedata format also covers SourceMod (the VDF `.games.txt`), while its ABI format is
/// a C++ header, because Metamod plugins are C++ and declare prototypes in source.
#[arg(long, default_value = "cssharp")]
format: String,
/// Confidence tier for a gamedata format (cumulative): core | high_confidence | experimental. Defaults to
@ -42,7 +51,18 @@ fn main() -> Result<()> {
let cli = Cli::parse();
let fmt = cli.format.as_str();
let text = if render::SCHEMA_FORMAT_IDS.contains(&fmt) {
// The INPUT selects the emitter family, which is why `cssharp` can name three different outputs.
let text = if let Some(path) = cli.abi.as_ref() {
let man: model::AbiManifest = serde_json::from_str(&std::fs::read_to_string(path)?)
.with_context(|| format!("parse abi manifest json {}", path.display()))?;
let e = render::abi_by_id(fmt).with_context(|| {
format!(
"unknown ABI --format `{fmt}` (known: {})",
render::ABI_FORMAT_IDS.join(" | ")
)
})?;
e.render(&man)
} else 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>",

View file

@ -154,11 +154,20 @@ impl Gamedata {
// ===========================================================================================
/// 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`).
/// (`core`, `valve-table`, `self-named`, `dict-exact`, `contextual`, `corroborated`, `high`, `medium`,
/// `low`). Declared most-confident first.
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Tier {
Core,
/// Named by a table Valve compiled into the binary being derived — today the entity-IO datadesc,
/// whose records pair a handler name with the handler itself. The only naming source that is neither
/// inferred nor transferred from another build, so it outranks every tier below it: the binary is
/// vouching for its own function names. (It is still not [`Tier::Core`], which additionally means
/// cross-build history — fingerprint verification and drift detection — that one build cannot supply.
/// The Pulse binding table names far more functions but does not LOCATE them; it ships as its own
/// registry, see [`Bindings`].)
ValveTable,
SelfNamed,
/// Dictionary-corroborated by the FOLD (an exact hit in the harvested name catalogue).
DictExact,
@ -200,7 +209,7 @@ pub struct Provenance {
pub by_value: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ret_class: Option<String>,
/// "catalogue" | "source2rosetta-nameext" | "contribution:<date>" | …
/// `"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")]
@ -230,6 +239,7 @@ impl Tier {
pub fn as_str(self) -> &'static str {
match self {
Tier::Core => "core",
Tier::ValveTable => "valve-table",
Tier::SelfNamed => "self-named",
Tier::DictExact => "dict-exact",
Tier::Contextual => "contextual",
@ -246,6 +256,7 @@ impl Tier {
pub fn from_id(s: &str) -> Option<Tier> {
Some(match s {
"core" => Tier::Core,
"valve-table" => Tier::ValveTable,
"self-named" => Tier::SelfNamed,
"dict-exact" => Tier::DictExact,
"contextual" => Tier::Contextual,
@ -289,12 +300,42 @@ pub struct MonoEntry {
/// 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>,
/// The argument footprint read out of THIS build's machine code — see [`AbiShape`]. Absent when the
/// function's address wasn't resolvable offline.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub abi: Option<AbiShape>,
pub provenance: Provenance,
/// Live-validation verdict: `Some(true)` passed, `Some(false)` dropped confident-bad, `None` unvalidated.
#[serde(default)]
pub validated: Option<bool>,
}
/// A function's SysV-AMD64 argument footprint, read out of the target binary rather than declared: how many
/// integer and float registers it takes as inputs, whether arguments also spill to the stack, and how it
/// returns. This is what makes a *declared* prototype checkable — a declaration whose arity contradicts the
/// footprint does not describe this build, and calling through it would load the wrong registers.
///
/// A lower bound, never an over-count (a forwarding thunk reads no argument register of its own), so a
/// disagreement is worth review rather than an automatic rejection.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct AbiShape {
/// Integer/pointer arguments, including an implicit `this`. Caps at 6 — the SysV register budget.
pub int: u8,
/// Floating arguments (XMM0..7).
pub float: u8,
/// Arguments also arrive on the stack: the real arity exceeds the register budget, so a caller filling
/// only registers is wrong.
#[serde(default, skip_serializing_if = "is_false")]
pub stack: bool,
/// Return class token: `ret=void` | `ret=int` | `ret=float` | `ret=byval` | `ret=?` (undetermined).
/// `ret=byval` is the sret case — UNSAFE to blind-call, since the caller must pass an output buffer.
pub ret: String,
}
fn is_false(b: &bool) -> bool {
!*b
}
/// A catalogued function the derivation could not confidently produce — kept in-file (never a shipped
/// locator) so the monolith is the complete catalogue picture.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
@ -395,6 +436,372 @@ impl Monolith {
}
}
// ===========================================================================================
// The binding registry — the shipped `bindings-<game>.json`. What the binary DECLARES about its own
// callable surface, as opposed to what the derivation infers about it: Valve registers every Pulse
// binding and entity-IO input with a name, author-facing documentation, and call metadata, and this is
// that data read back out. Kept OUT of the monolith on purpose — the monolith answers "where is this
// function", this answers "what may I do with it", and only the first belongs in a gamedata file.
// ===========================================================================================
/// How a Pulse binding is invoked — the receiver question, which decides whether a caller needs an
/// entity at all. Mutually exclusive by construction (the two flag bytes are never set together).
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum BindingKind {
/// A free function on a Pulse library class (`CPulseMathlib::Sin`) — no receiver.
Library,
/// A method on an entity API (`CBaseEntityAPI::GetAbsOrigin`) — needs an instance receiver.
Instance,
/// A Pulse cell's own entry point (`CPulseCell_Step_DebugLog::Run`) — invoked by the VM as it walks
/// a graph, not called by a graph author.
Cell,
}
/// The call metadata Valve records for a binding — a typed graph VM cannot register a binding without
/// knowing how it may be invoked, so this is the engine's own policy, read back rather than reasoned out.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct CallPolicy {
pub kind: BindingKind,
/// The binding writes state. The const-correctness axis: a caller that only observes can be run
/// where a mutation may not be.
pub mutates: bool,
/// The binding may suspend the calling cursor instead of completing within the frame.
pub blocking: bool,
/// The two metadata words exactly as read, so a consumer can re-derive meaning if a later build
/// repurposes a bit rather than silently inheriting this decoding.
pub raw: [u32; 2],
}
/// One Pulse binding: where it is, what Valve calls it, and how it may be invoked.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct Binding {
/// The module registering it — `server`, `pulse_system`, …
pub library: String,
/// The author-facing label ("Get Abs Origin").
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display: Option<String>,
/// The author-facing documentation ("The entity origin (absolute).").
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub policy: CallPolicy,
/// The binding's declared parameters, in order, recovered from the descriptor accessor's own
/// initializer. Pulse is a TYPED graph VM, so this is the engine's own statement of how the
/// binding is called — not a transfer from another game and not a 2018-era declaration.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub params: Vec<PulseParam>,
/// The values it hands back. Pulse models returns as named out-parameters, so this is a LIST:
/// usually one `retval`, empty for a void binding, occasionally several.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub returns: Vec<PulseParam>,
/// Whether the signature above was recovered at all. An empty `params` on an untyped binding means
/// "not read"; on a typed one it means "takes nothing", and only this field separates them.
pub typed: bool,
/// Address of the binding's DESCRIPTOR ACCESSOR in this build — a lazy-init singleton returning the
/// static descriptor, not the bound function. It is the anchor a runtime walks to reach the
/// descriptor (and, through it, the real entry point); it is NOT a locator for the named method, and
/// no shipped gamedata entry points at it.
pub descriptor: String,
}
/// One Pulse parameter or return value, as the binding declares it.
///
/// Defined here rather than beside the reader so the deriver-free core crate can carry it: `gen` emits
/// these, and nothing about the shape depends on how it was read out of the binary.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct PulseParam {
/// The author-facing parameter name — `_Target` for the receiver, `retval` for a return.
pub name: String,
/// The `PulseValueType_t` enumerator, as the binary states it. Join it to the `enums` section of
/// `netvars-<game>.json` for the spelling; the raw value is kept because that is the fact.
#[serde(rename = "type")]
pub ty: i32,
/// The schema type the value refers to, where the binding NAMES one — which enum a
/// `PVAL_SCHEMA_ENUM` is, which struct an opaque handle wraps. Absent for the self-describing
/// types, and absent for a `PVAL_EHANDLE`'s entity class, which the initializer does not state —
/// see `entity_class` for where that comes from instead.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub type_name: Option<String>,
/// The entity class behind a `PVAL_EHANDLE` — `func_mover`, `basemodelentity`. NOT read from the
/// initializer, which never states it: it is Valve's own naming (`mappings/ehandle-classes.json`,
/// harvested from the published metadata) propagated across the parameters this build proves are
/// the same type. See [`PulseParam::type_token`] for what proves it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entity_class: Option<String>,
/// An opaque per-build token identifying the parameter's CONCRETE type: the address of that type's
/// destructor, which the initializer stores beside the name. Two parameters carry the same token
/// exactly when they are the same type — including the entity class behind a `PVAL_EHANDLE`, which
/// nothing else in the binary distinguishes.
///
/// NOT serialized, deliberately. An address is meaningless outside the build it was read from, so
/// shipping it would invite a consumer to key on something that moves every release. It exists to
/// carry the grouping from the reader to the stage that names it, and no further.
#[serde(skip)]
pub type_token: u64,
}
/// One entity-IO input handler: the name a map fires, and the C++ method that answers it.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct EntityInput {
/// The input name entity IO addresses — `Kill`, `Enable`, `SetSpeed`.
pub input: String,
/// The class that owns this handler, recovered from the FIELD descriptors sharing its datadesc
/// array: a field is a `(member, offset)` pair the SchemaSystem states independently, so the class
/// whose schema contains every pair in the array owns it. Absent where the array's fingerprint fit
/// several classes or none — `InputEnable` is a distinct handler on 48 classes and guessing between
/// them is the thing this exists to stop.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub class: Option<String>,
/// The C++ handler — `InputKill`. NOT class-qualified: the record carries no owning class, which is
/// also why the same handler name legitimately appears at many addresses here.
pub handler: String,
pub library: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub abi: Option<AbiShape>,
pub addr: String,
}
/// One registered console command: the name a server operator or a mod types, and the function that
/// answers it. Unlike the Pulse registry this carries a real locator — the handler comes from the same
/// registration call as the name, not from a descriptor accessor beside it.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct ConsoleCommand {
/// The console-facing name, exactly as Valve compiled it — `bot_add`, `+bugvoice`.
pub name: String,
pub library: String,
/// Valve's own help text; absent when the registration passes none.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub description: String,
/// The flag bits whose meaning is measured against Valve's published dump.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub flags: Vec<String>,
/// The raw flags word, kept beside the decoding so a build that repurposes a bit can be re-read
/// instead of silently mis-labelled — the same rule the Pulse policy word follows.
pub flags_raw: String,
/// How the registration passed its callback: `direct`, `interface` or `member`. Says how much
/// indirection produced the address, which is the honest measure of how far this is from the
/// instruction stream.
pub form: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub abi: Option<AbiShape>,
pub addr: String,
}
/// One entity-IO output: an event an entity fires, and where its subscriber list lives on the instance.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct EntityOutput {
/// The entity-IO name a map or a mod wires to, e.g. `OnStartTouch`.
pub output: String,
/// The member holding it, e.g. `m_OnStartTouch` — usually the name with `m_` prepended, but not
/// reliably (`BombExplode` lives on `m_OnBombExplode`), so both ship.
pub member: String,
pub library: String,
/// Byte offset of the member within its entity — an output is data, not a function, so this is the
/// locator. (`CEntityIOOutput` is 24 bytes; see the `types` section of the schema artifact.)
pub offset: u32,
/// The class the member lives on, recovered by joining `(member, offset)` against the SchemaSystem.
/// Without it an offset is unusable wherever a name repeats: `OnBreak` exists at three different
/// offsets on three different classes, and reading the wrong one runs ~1 KB past the intended member
/// on a live entity. `None` only when the schema does not describe the member.
///
/// NB the member's TYPE is not carried here: the offline schema reader recovers names and offsets but
/// not types (those are runtime-resolved), and a handful of outputs are not the plain 24-byte
/// `CEntityIOOutput` — `CLogicCase::m_OnCase` is `CEntityIOOutput[32]`. Join `class` + `member`
/// against `netvars-<game>.json` for the type before striding one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub class: Option<String>,
}
/// The declared callable surface for one build — the shipped `bindings-<game>.json`.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct Bindings {
pub meta: BindingsMeta,
/// Pulse bindings, keyed by their fully-qualified `Class::Method`.
pub pulse: BTreeMap<String, Binding>,
/// Entity-IO inputs, as a LIST: the handler name is not unique (one `InputEnable` per class), so
/// there is no honest key to map them by.
pub entity_inputs: Vec<EntityInput>,
/// Entity-IO outputs — the events an entity fires, as a LIST for the same reason as the inputs.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub entity_outputs: Vec<EntityOutput>,
/// Map classname -> the C++ class it constructs (`func_door` -> `CBaseDoor`). The join between the
/// vocabulary a level designer writes and the classes `netvars-<game>.json` describes. No addresses:
/// the factory record binds names to names, not to a function.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub entity_classes: BTreeMap<String, String>,
/// Console commands, as a LIST: a handful of names are registered by more than one library, so
/// there is no honest key to map them by either.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub commands: Vec<ConsoleCommand>,
}
/// The binding registry's intrinsic identity (no wall-clock field, same rationale as [`MonoMeta`]).
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct BindingsMeta {
pub game_key: String,
pub source_build: String,
pub pulse: usize,
/// Of those, how many carry a recovered typed signature.
#[serde(default)]
pub pulse_typed: usize,
pub entity_inputs: usize,
#[serde(default)]
pub entity_outputs: usize,
#[serde(default)]
pub entity_classes: usize,
#[serde(default)]
pub commands: usize,
}
impl Bindings {
pub fn is_empty(&self) -> bool {
self.pulse.is_empty()
&& self.entity_inputs.is_empty()
&& self.entity_outputs.is_empty()
&& self.entity_classes.is_empty()
&& self.commands.is_empty()
}
}
// ===========================================================================================
// The prototype manifest — the shipped `abi-<game>.json`. What a function TAKES, which the gamedata
// deliberately does not answer: a locator says where a function is, a prototype says how to call it, and
// the two have different sources and different lifetimes. Declarations are static and human-sourced;
// the VERDICT on each one is re-measured against every build.
// ===========================================================================================
/// The verdict on a declared prototype, judged against the footprint measured in THIS build.
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum AbiStatus {
/// The declared arity matches the measured footprint — safe to call through.
Verified,
/// The callee reads a register the declaration does not mention. The declaration does not describe
/// this build and MUST NOT be called through: such a call resolves, passes live validation, and then
/// leaves a register the callee reads unset.
Mismatch,
/// The declaration passes registers the callee never reads, and contradicts it in no register class.
///
/// NOT a mismatch, and separating the two is the point. The measured footprint is a documented LOWER
/// bound — a callee that ignores an argument, a forwarding thunk that reads none of its own, an
/// empty virtual override — so measuring FEWER arguments than declared is expected behaviour rather
/// than evidence against the declaration. `CBaseEntity::SetAbsAngles` is the shape: declared
/// `(this, float, float, float)`, measured `int=0 float=3`, every float agreeing exactly and only
/// the unread `this` differing. Calling through one merely loads a register nobody reads, which is
/// the opposite of the failure `Mismatch` names. 81 of CS2's 140 former mismatches are this.
LowerBound,
/// No measurement available to check it against.
Unverified,
/// Overloads the measurement could not separate.
Ambiguous,
/// A return type was declared and a parameter list was not, so there is no arity claim for the
/// binary to confirm or refute. `CALL_VIRTUAL(RET, …)` sites are the source: they say what comes
/// back and pass VALUES rather than types, so nothing about the signature can be read off them.
ReturnOnly,
}
impl AbiStatus {
pub fn as_str(self) -> &'static str {
match self {
AbiStatus::Verified => "verified",
AbiStatus::Mismatch => "mismatch",
AbiStatus::LowerBound => "lower-bound",
AbiStatus::Unverified => "unverified",
AbiStatus::Ambiguous => "ambiguous",
AbiStatus::ReturnOnly => "return-only",
}
}
}
/// One function's declared prototype and the verdict this build gives it.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct AbiEntry {
/// Which shipped tier the function is in.
pub tier: String,
/// `exact` — the declaration names this function; `bare-name` — it names the same METHOD on some
/// class, claimed only because exactly one declaration bears that name and a measurement could
/// adjudicate.
pub matched_by: String,
pub status: AbiStatus,
/// Declared parameter TYPES — the thing a machine-derived arity cannot supply.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub params: Option<Vec<String>>,
/// Set when `params` is the FULL register-visible argument list, receiver included, because the
/// declaration was a function-pointer type rather than a mangled symbol. Absent means `this` is not
/// in the list and a caller has to supply it — the convention every Itanium-derived entry uses.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub params_complete: Option<bool>,
#[serde(rename = "const", default, skip_serializing_if = "Option::is_none")]
pub is_const: Option<bool>,
/// A DECLARED return type where one exists, else the measured register class — a far weaker
/// statement (see [`AbiShape`]), and one that is not evidence about the declared type.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ret: Option<String>,
/// Omitted on an `ambiguous` verdict, where no declaration was chosen.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub provenance: Vec<String>,
/// The measured SysV footprint the verdict was reached against.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub derived: Option<AbiShape>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
/// Every signature the declarations offered, whenever there was more than one — whether the
/// measurement went on to pick between them (see `note`) or could not, which is `ambiguous`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub overloads: Option<Vec<Vec<String>>>,
/// The vtable slot this build's shipped locator resolves through, present exactly when the locator
/// is a vtable offset that LIVE VALIDATION confirmed.
///
/// It states something no measurement can: a slot is only reachable through an object, so the
/// function HAS a receiver even where the footprint cannot see one. That case is not marginal —
/// a getter that returns a constant never reads `this`, and backward liveness reads that as no
/// argument at all, so `CBaseDoor::GetDataDescMap` measures `int=0` while genuinely taking one.
///
/// Live validation is part of the condition rather than a separate check, because it is what rules
/// out the one way an `offset` locator can fail to be a vtable slot: a carried MEMBER offset under a
/// name the deriver could not classify, which the live oracle reports as `Unknown`/`Oob` instead of
/// `Live`. An offline derive has no such evidence and therefore states no `vtable` at all —
/// a smaller emittable set, never a guessed receiver.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vtable: Option<i64>,
}
impl AbiEntry {
/// An entry with only the fields every verdict carries — the base for functional-update construction.
pub fn blank() -> Self {
Self {
tier: String::new(),
matched_by: String::new(),
status: AbiStatus::Unverified,
params: None,
params_complete: None,
is_const: None,
ret: None,
provenance: Vec::new(),
derived: None,
note: None,
overloads: None,
vtable: None,
}
}
}
/// The prototype manifest's identity plus its verdict tally.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct AbiMeta {
pub game_key: String,
pub source_build: String,
/// Verdict tally — `status:verified`, `core:resolved`, `high_confidence:none`, …
pub counts: BTreeMap<String, usize>,
}
/// The shipped `abi-<game>.json`.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct AbiManifest {
pub meta: AbiMeta,
pub functions: BTreeMap<String, AbiEntry>,
}
/// One typed schema field (the field NAME is the map key). `offset` is static; `ty`/`kind`/`size` are
/// runtime-resolved (empty/zero when derived offline without a live process).
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
@ -422,12 +829,99 @@ pub enum FieldKind {
FixedArray,
}
/// A direct base class and the `this`-adjustment to reach it. The schema records these, and a consumer
/// needs them for the artifact's most basic question: `CCSPlayerPawn.m_iHealth` is not a field OF
/// `CCSPlayerPawn` — it is inherited, and only the base chain says where to look.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct BaseClass {
pub name: String,
pub offset: u32,
}
/// How a type travels when passed BY VALUE under the SysV-AMD64 convention — the fact a caller needs
/// that a field offset cannot supply.
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SysvClass {
/// Travels in integer registers: pointers, handles, and small aggregates with any non-float member.
Integer,
/// Travels in SSE registers — an aggregate of 16 bytes or less whose members are all floating-point.
/// `Vector` (3 floats) is the case that bites: by value it costs TWO SSE registers, by reference one
/// integer register.
Sse,
/// Larger than 16 bytes, so it is passed in memory (effectively by reference) and returned through a
/// hidden pointer. Size alone settles this one.
Memory,
/// Size known but composition unknown, or size unknown — a caller must not guess.
Unknown,
}
/// Where a type's size came from — the honesty axis, since the two routes carry different confidence.
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum LayoutSource {
/// The SchemaSystem states the class's instance size outright.
Schema,
/// Inferred from the distance to the next field across many classes — exact for every primitive whose
/// size is independently known, but an inference nonetheless.
FieldGap,
/// Declared in the deriver, for the closed set of engine primitives the schema does not register.
Declared,
}
/// What a consumer needs to pass or hold a value of some type: how big it is, and how it travels.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct TypeLayout {
pub size: usize,
pub sysv: SysvClass,
pub source: LayoutSource,
/// How many field observations backed a `field-gap` size, and how many agreed — omitted for the other
/// sources, where the size is stated rather than inferred.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub observations: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agreement: Option<usize>,
}
/// One enumerator: the name and the value it stands for.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct EnumValue {
pub name: String,
pub value: i64,
}
/// A registered enum — the semantic vocabulary behind an integer field. `m_MoveType = 2` is only
/// meaningful as `MOVETYPE_WALK`, and a register footprint can never recover that.
///
/// Unlike a field's TYPE, this is static data: the SchemaSystem records enum bindings in the binary, so
/// these are read from the image rather than from the running process.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct EnumDef {
/// Underlying integer width in bytes — 1 for `MoveType_t`, 4 for `gear_slot_t`. Recorded by the
/// binding, so the distinction is read rather than assumed.
pub size: u8,
/// Enumerators in DECLARATION order. A list, not a map: names are unique but values are not, since
/// aliases (`MOVETYPE_LAST` / `MOVETYPE_INVALID`) legitimately share one.
pub values: Vec<EnumValue>,
}
/// The typed schema — the shipped `netvars-<game>.json`. Merges field offsets with runtime types:
/// class -> field -> [`Field`].
/// class -> field -> [`Field`], plus the enum vocabulary those fields refer to.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct Schema {
pub meta: SchemaMeta,
pub classes: BTreeMap<String, BTreeMap<String, Field>>,
/// Direct base classes per class. `classes` holds each class's OWN fields only, so resolving an
/// inherited member means walking this.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub bases: BTreeMap<String, Vec<BaseClass>>,
/// Registered enums by name. Empty on an older artifact that predates the enum walk.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub enums: BTreeMap<String, EnumDef>,
/// Size + SysV class for every type the fields above refer to — what a caller needs in order to pass
/// one, which neither an offset nor a register footprint can supply.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub types: BTreeMap<String, TypeLayout>,
}
/// The typed schema's intrinsic identity (no wall-clock field, same rationale as [`MonoMeta`]).
@ -437,6 +931,12 @@ pub struct SchemaMeta {
pub source_build: String,
pub typed: usize,
pub untyped: usize,
/// Registered enums recovered (0 on an artifact that predates the enum walk).
#[serde(default)]
pub enums: usize,
/// Types with a recovered size + SysV class.
#[serde(default)]
pub types: usize,
}
#[cfg(test)]
@ -451,6 +951,7 @@ mod monolith_tests {
offset: Some(158),
},
class: None,
abi: None,
provenance: Provenance {
source: Some("catalogue".into()),
..Provenance::with_tier(Tier::Core)
@ -474,6 +975,7 @@ mod monolith_tests {
offset: Some(40),
},
class: Some("CFoo".into()),
abi: None,
provenance: Provenance {
confidence: Some("low".into()),
self_named: Some(false),
@ -521,6 +1023,7 @@ mod monolith_tests {
offset: None,
},
class: None,
abi: None,
provenance: Provenance {
source: Some("catalogue".into()),
..Provenance::with_tier(Tier::Core)

File diff suppressed because it is too large Load diff