read what the binary says about itself: names, signatures, prototypes; gen v2
This commit is contained in:
parent
54ef572202
commit
c458b4cb50
34 changed files with 58363 additions and 192 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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`:
|
||||
|
|
|
|||
|
|
@ -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>",
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue