ship one record per function: merge the release set, gen reads it, descriptions as doc comments, gates for what was only claimed; v3.0
Some checks failed
CI / fuzz (push) Successful in 2m2s
CI / lint (push) Successful in 15s
CI / test (push) Failing after 18s

This commit is contained in:
Kamal Tufekcic 2026-08-02 22:01:36 +03:00
commit 3410a79b6a
28 changed files with 30596 additions and 955 deletions

View file

@ -1,13 +1,14 @@
# 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 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.
Render a published [source2rosetta](../../README.md) 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 **one file per game**, `rosetta-<game>.json`.
`source2rosetta-gen` turns that into CounterStrikeSharp, Metamod/SourceMod, ModSharp, Swiftly, Plugify, a
typed C# SDK, or the Dota script API, 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.
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
@ -23,109 +24,140 @@ does **not** build it — use `-p source2rosetta-core` or `--workspace`.)
## Use it
Three of the published artifacts are `gen` inputs, one per `--` flag:
- `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.
One input, one flag. `--format` says **who the output is for**; `--out` is a **directory**, because most
formats write more than one file.
```sh
# CounterStrikeSharp combined gamedata (the default)
source2rosetta-gen --from gamedata-cs2.json --format cssharp --out gamedata.json
R=https://git.lo.sh/kamal/source2rosetta/releases/download/cs2-latest
curl -fsSLO $R/rosetta-cs2.json
# Metamod / SourceMod gamedata VDF (one .games.txt)
source2rosetta-gen --from gamedata-cs2.json --format metamod --out csgo.games.txt
# CounterStrikeSharp: the combined gamedata + typed call sites for the same functions
source2rosetta-gen --from rosetta-cs2.json --format cssharp --out ./csharp
# Swiftly / ModSharp / Plugify gamedata
source2rosetta-gen --from gamedata-cs2.json --format swiftly --out gamedata.json
# Metamod:Source / SourceMod: the gamedata VDF + a C++ prototype header
source2rosetta-gen --from rosetta-cs2.json --format metamod --out ./mm
# 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
# A typed C# SDK from the schema — one `static class` per engine class, `const` offsets + types
source2rosetta-gen --from rosetta-cs2.json --format cs-sdk --out ./sdk
# Flat netvar offset map (class -> field -> offset)
source2rosetta-gen --netvars netvars-cs2.json --format netvars --out netvars.json
# The Dota script API: ModDota's dota-data shape AND the TypeScript declarations
source2rosetta-gen --from rosetta-dota2.json --format moddota --out ./dota
```
Each run prints what it wrote.
## Formats
| `--format` | needs | output |
**A framework gets two files, and it needs both.** The gamedata says *where* a function is; the call sites say
*how to call it*. They were separate inputs when the release was four files; one artifact makes them one
command.
| `--format` | writes | notes |
|---|---|---|
| `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 — **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 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 } }` |
| `cssharp` *(default)* | `gamedata.json` + `RosettaFunctions.cs` | the gamedata is **JSONC** — banner comments, so a strict JSON parser will reject it. The `.cs` is `MemoryFunction*` fields / `VirtualFunction*` factories |
| `metamod` | `<game_key>.games.txt` + `rosetta_prototypes.h` | the VDF also covers SourceMod. Metamod plugins are C++, so the prototypes are a header of `using X_t = RET (*)(…)` plus an `X_vtidx` constant per slot |
| `modsharp` | `gamedata.json` + `RosettaCalls.cs` | `[AddressKey]` interface for its Roslyn generator, plus a vtable-dispatch class |
| `swiftly` | `gamedata.json` + `prototypes.json` | **signature entries only** in the gamedata; that framework takes offsets through a separate file |
| `plugify` | `gamedata.json` + `prototypes.json` | runtime type arrays (`{"paramTypes":["pointer","string"],"retType":"void"}`) |
| `cs-sdk` | `Schema.cs` | typed C# SDK: `static class` per schema class, `const int` field offsets tagged with their type, plus the engine's own enums at their real width |
| `netvars` | `netvars.json` | flat schema map, `{ class: { field: offset } }` |
| `moddota` | `api.json` + `api.d.ts` | the VScript API in ModDota `dota-data`'s shape (their toolchain renders from it), plus TypeScript declarations for authors using the published packages as-is — one `interface` per class, Valve's own description as the doc comment |
| `flat` | `gamedata-flat.json` | the selected tiers as one name → locator map, format-neutral |
### Call shapes — `--abi abi-<game>.json`
**Every published artifact renders every format on this list.** The releases are always derived against a
running server, so nothing here is conditional on how the artifact was made. (If you derive your own, that
changes — see [below](#if-you-derived-the-artifact-yourself).)
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.
### Which games a format covers
All five framework ids work here, exactly as they do for `--from`:
**The artifact states its own game** (`meta.game_key`, `csgo` or `dota`) and the output follows it — there is
no `--game` flag, because a second place to state one fact is a second place for it to be wrong.
```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
```
Two formats are **game-keyed**, and for both the key is the game DIRECTORY the server runs out of, which is
what `game_key` already holds:
| `--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"}`) |
- `metamod` writes `Games { <game_key> { … } }`. The consuming plugin looks that section up by the engine's
own `GetGameDir()` — see [cs2kz-metamod's reader][kz] — so `dota` is what a Dota 2 plugin will look for.
Metamod takes Dota 2 as a first-class SDK target ([`dota.json`][mm], `define: DOTA`, `source2: true`).
- `plugify` writes `{ "<game_key>": { … } }`, matched against the `S2SDK_GAME_NAME` its s2sdk plugin was
BUILT with (default `csgo`).
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 rest are game-neutral in shape: `modsharp` and `cssharp` carry no game key at all (flat, keyed only by
platform), and `flat` / `cs-sdk` / `netvars` / `moddota` are plain data.
**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.
**Two consumers cannot run on Dota 2 at all**, and `gen` declines rather than write a file that can never
load: CounterStrikeSharp resolves its binaries out of `<dir>/csgo/bin/`, and Swiftly initialises against the
`csgo` game directory. `--force` renders anyway. It is a warning rather than a rule on purpose — that is a
claim about somebody else's project, read out of their source at one point in time, and projects add games.
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.
**ModSharp is CS2-first but not excluded.** Its own paths are hardcoded (`../../csgo/steam.inf`), yet its
gamedata carries no game key whatsoever — flat `Addresses` / `VFuncs`, platform-keyed — so the file rendered
here is the same one whatever game the build targets.
**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.
[kz]: https://github.com/KZGlobalTeam/cs2kz-metamod/blob/dev/src/utils/gameconfig.cpp
[mm]: https://github.com/alliedmodders/hl2sdk-manifests/blob/master/manifests/dota.json
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.
### Descriptions
**Every call site is emitted with a sentence saying what the function is FOR**, wherever that target's
readers hover: a C# XML `<summary>`, so IntelliSense shows it; a comment above the C++ typedef; a
`description` field in the data formats. The prototype keeps a home of its own — a `<remarks>` in C#, the
identity line in the header — so nothing is lost to make room. That is every emittable call site: 2,073 on
CS2, 2,326 on Dota.
**Each one says whose sentence it is, and that is not decoration.** Some are Valve's own, read out of a
registry in the binary (717 CS2 / 774 Dota); the rest are this project's reading of the build, and the two
carry very different weight. So every generated source prints the origin beside the text, the data formats
carry it as an id (`valve` / `derived` / `generated`), and `moddota` — which deliberately mirrors a shape the
Dota ecosystem already publishes — keeps ours under keys of our own name rather than in the `description`
field their toolchain renders as Valve's word.
**Valve's text always wins.** Where the binary documents a function, that is what ships; a generated
description only ever fills a gap, so the two can never disagree in an output. Between Valve's own two
registries the script one wins: a console registration's help text documents the COMMAND an operator types,
while a script binding documents the function. In the `.d.ts`, a member Valve documents reads exactly as it
did before — bare, the way the published types do — and only a gap Valve left is filled and marked.
`cs-sdk` and `netvars` render the schema, which is classes and field offsets. There are no functions in them
to describe, so they carry none.
### What the call sites will and won't emit
Only functions the deriver could stand behind: `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 (`ret_declared` / `retDeclared` in the data formats, prose in the generated
source), and the value is documented as the raw return register rather than a typed result.
**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 the
call sites are vtable-located.
**A few entries carry BOTH**, which is worth knowing if you consume the gamedata rather than these call
sites: `model::Entry` allows it and five CS2 `core` entries use it. Both locators are live-validated
independently, and `validated: true` means both passed — an entry whose signature checked out but whose slot
could not be reached ships `null`, never `true`. The call-site emitters here pick one form per function, so
this only affects what you read out of the artifact directly.
**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.
**Two things `moddota` states honestly rather than guesses.** Parameters are declared `...args: any[]`,
because the registry does not carry them — types appear only inside Valve's prose descriptions, inconsistently,
in about a fifth of entries. It is visibly ugly on purpose: nobody should mistake these for complete
declarations, and inventing plausible arity would emit declarations that lie rather than abstain. And
`available` is always `server`, because a dedicated server never maps `libclient`, so this derivation cannot
see the client side at all.
## Confidence tier
The gamedata formats (the `--from` ones) take a `--tier`, cumulative and defaulting to `high_confidence`:
The locator half takes a `--tier`, cumulative and defaulting to `high_confidence`:
| `--tier` | includes |
|---|---|
@ -135,10 +167,40 @@ The gamedata formats (the `--from` ones) take a `--tier`, cumulative and default
```sh
# only the rock-solid set:
source2rosetta-gen --from gamedata-cs2.json --format cssharp --tier core --out gamedata.json
source2rosetta-gen --from rosetta-cs2.json --format cssharp --tier core --out ./csharp
```
The schema formats (`cs-sdk`, `netvars`) ignore `--tier`.
The schema and script-API formats ignore `--tier`.
## What is in the artifact that `gen` does not render
`rosetta-<game>.json` is plain JSON and readable as-is, and it carries more than these formats consume: the
Pulse binding registry (typed signatures, call policy, and a callable shim per binding), entity outputs,
map classname → C++ class, ConVars with decoded flags, and the declared rows that belong to no function here
(`surfaces.unjoined`). Those have no loader to render them into — read them directly. The per-function
descriptions are in there too, and they DO reach the outputs, but only for the ~2,000 functions that become
call sites; the artifact describes about twice as many. See the [artifacts section](../../README.md#artifacts-schemas--output-formats).
## If you derived the artifact yourself
Skip this if you downloaded the artifact — it is about `source2rosetta produce`, not about `gen`.
The releases are derived against a **running server**, which is where two whole surfaces come from. Run
`produce` without `--game-dir` and it derives what it can offline, honestly, and states the rest as absent —
so `gen` gets an artifact that is real but smaller, and three things follow:
- **`cs-sdk`, `netvars` and `moddota` have nothing to render**, and say so rather than writing an empty file.
The schema's field TYPES are runtime-resolved, and a VScript binding's owning class is reached through a
register loaded from memory, so neither is readable from the file alone. All three group by one or the
other.
- **No call site is reached through a vtable.** A slot is only recorded 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. Roughly a quarter of a live manifest's call sites are vtable-located; an offline one has none. Same
shape, fewer entries — worth knowing before diffing two outputs made different ways.
- **Nothing has been checked against a running server.** `validated` is `null` on every record rather than a
verdict, and the locator half drops only what live validation confidently REJECTED — so an offline artifact
keeps entries a live one would have thrown out. The tiers still mean what they mean; they just have not
been tested.
---

View file

@ -1,107 +1,211 @@
//! `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.
//! `source2rosetta-gen` — the standalone generator. Reads the published `rosetta-<game>.json` and writes
//! the files one consumer needs: a framework's gamedata plus the typed call sites that resolve through it,
//! a typed schema SDK, or the script API the Dota ecosystem publishes.
//!
//! 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
//! who "just wants the files" downloads one artifact and 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;
use std::path::{Path, PathBuf};
#[derive(Parser)]
#[command(
name = "source2rosetta-gen",
version,
about = "Render a source2rosetta monolith into a framework gamedata format"
about = "Render a source2rosetta release into the files your framework reads"
)]
struct Cli {
/// The monolith `gamedata-<game>.json` (for a GAMEDATA --format: cssharp/metamod/modsharp/swiftly/plugify/model).
/// The published `rosetta-<game>.json` — one artifact holding every surface.
#[arg(long)]
from: Option<PathBuf>,
/// 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). 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.
from: PathBuf,
/// Who the output is for, and it writes every file that consumer reads. FRAMEWORKS get a gamedata
/// file and the typed call sites that resolve through it: cssharp | metamod (also SourceMod's VDF) |
/// modsharp | swiftly | plugify. SCHEMA: cs-sdk (typed C# SDK) | netvars (flat offset map). SCRIPT
/// API: moddota, which writes both shapes that ecosystem publishes (`api.json` + `api.d.ts`). Plus
/// `flat`, a format-neutral name -> locator map.
#[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.
/// Confidence tier for the locator half, cumulative: core | high_confidence | experimental. Defaults to
/// `high_confidence` (core + the promoted names). Ignored by the schema and script-API formats.
#[arg(long, default_value = "high_confidence")]
tier: String,
/// Write here (default: stdout).
/// Directory to write into (default: the working directory). A format writes more than one file, so
/// this names a DIRECTORY rather than a file — the names are the ones each framework expects.
#[arg(long, default_value = ".")]
out: PathBuf,
/// Render even where the consumer is not known to run on this artifact's game. What that claim rests
/// on is in `render::GAME_SUPPORT`; it is read out of somebody else's source and they do add games,
/// so it declines by default rather than refusing outright.
#[arg(long)]
out: Option<PathBuf>,
force: bool,
}
/// One file to write: the name the consuming framework expects, and what goes in it.
struct Out {
name: String,
text: String,
/// What this file is, for the line printed after writing it.
what: &'static str,
}
fn main() -> Result<()> {
let cli = Cli::parse();
let fmt = cli.format.as_str();
// 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>",
)?;
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)"
),
let text = std::fs::read_to_string(&cli.from)
.with_context(|| format!("read {}", cli.from.display()))?;
let r: model::Rosetta = serde_json::from_str(&text)
.with_context(|| format!("parse {} as a rosetta artifact", cli.from.display()))?;
let tier = model::TierSelect::from_id(&cli.tier).with_context(|| {
format!(
"unknown --tier {:?} (want one of: {})",
cli.tier,
model::TIER_IDS.join(" | ")
)
})?;
// Checked before anything is rendered: a file that cannot load on the game it was made for is worse
// than no file, and the one thing worse than that is one written silently.
if let Some(mismatch) = render::game_mismatch(fmt, &r.meta.game_key) {
if !cli.force {
bail!("{mismatch}\n Pass --force to render it anyway.");
}
eprintln!("warning: {mismatch}\n Rendering anyway (--force).");
}
let outputs = match fmt {
// A framework gets the pair: WHERE the functions are, and HOW to call them. They were two
// invocations against two files; one artifact makes them one command, and a consumer who has the
// locators without the call sites has half of what it takes to make a call.
f @ ("cssharp" | "metamod" | "modsharp" | "swiftly" | "plugify") => {
let gd = match f {
// cssharp is the bannered combined file (guaranteed + extrapolated sections), not a flat map.
"cssharp" => render::render_monolith_cssharp(&r.to_monolith(), tier),
_ => render::by_id(f)
.expect("known flat format")
.render(&r.gamedata(tier)),
};
let calls = render::abi_by_id(f)
.expect("known abi format")
.render(&r.abi_manifest());
let (gd_name, calls_name) = match f {
"cssharp" => ("gamedata.json".into(), "RosettaFunctions.cs"),
"metamod" => (
format!("{}.games.txt", r.meta.game_key),
"rosetta_prototypes.h",
),
"modsharp" => ("gamedata.json".into(), "RosettaCalls.cs"),
_ => ("gamedata.json".into(), "prototypes.json"),
};
vec![
Out {
name: gd_name,
text: gd,
what: "locators",
},
Out {
name: calls_name.into(),
text: calls,
what: "typed call sites",
},
]
}
"flat" => vec![Out {
name: "gamedata-flat.json".into(),
text: render::by_id("model")
.expect("known flat format")
.render(&r.gamedata(tier)),
what: "locators, format-neutral",
}],
f @ ("cs-sdk" | "netvars") => {
// Field types are runtime-resolved, so an offline build states no schema at all. Saying so is
// better than writing an empty SDK that compiles and describes nothing.
let schema = r.typed_schema().context(
"this artifact's `schema` is null, so the schema formats have nothing to render — field \
TYPES are resolved at runtime and are not in the file. Every PUBLISHED artifact carries \
one, so this is a local OFFLINE derive: re-run `produce` with --game-dir, or take the \
artifact from a release.",
)?;
let text = render::schema_by_id(f)
.expect("known schema format")
.render(&schema);
vec![Out {
name: if f == "cs-sdk" {
"Schema.cs".into()
} else {
"netvars.json".into()
},
text,
what: "typed schema",
}]
}
// One consumer, two files, for the same reason a framework gets two: the ModDota ecosystem's
// toolchain renders from the JSON, while an author working against the published packages reads
// the declarations. Emitting one of them is answering half the question.
"moddota" => {
let vscript = r.vscript();
// Both shapes group members by owning class, and the class is only readable from a running
// server — so an offline artifact renders nothing, and an empty file would look like an
// answer rather than a missing input. The two ways of having nothing to render are worth
// telling apart: a game with no script VM at all is not a build that was run offline.
if vscript.is_empty() {
bail!(
"this artifact carries no VScript bindings at all, so there is no script API to \
render. Only Dota 2 and CS2 expose one check you passed the right artifact."
);
}
if vscript.iter().all(|v| v.class.is_none()) {
bail!(
"this artifact has {} VScript bindings and no owning class on any of them, and both \
shapes group members BY class. The owning class is only readable from a running \
server, and every PUBLISHED artifact carries it, so this is a local OFFLINE derive: \
re-run `produce` with --game-dir, or take the artifact from a release.",
vscript.len()
);
}
let schema = r.typed_schema();
let render_as = |id: &str| {
render::bindings_by_id(id)
.expect("known bindings format")
.render(&vscript, schema.as_ref())
};
vec![
Out {
name: "api.json".into(),
text: render_as("api-json"),
what: "script API, ModDota `dota-data` shape",
},
Out {
name: "api.d.ts".into(),
text: render_as("dts"),
what: "script API, TypeScript declarations",
},
]
}
other => bail!(
"unknown --format {other:?} (want one of: {})",
render::FORMAT_IDS.join(" | "),
),
};
match cli.out {
Some(p) => std::fs::write(&p, text).with_context(|| format!("write {}", p.display()))?,
None => println!("{text}"),
write_all(&cli.out, &outputs)
}
fn write_all(dir: &Path, outputs: &[Out]) -> Result<()> {
std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
for o in outputs {
let p = dir.join(&o.name);
std::fs::write(&p, &o.text).with_context(|| format!("write {}", p.display()))?;
println!(
"{} ({}, {} KB)",
p.display(),
o.what,
o.text.len().div_ceil(1024)
);
}
Ok(())
}

File diff suppressed because it is too large Load diff

View file

@ -3,8 +3,11 @@
//! 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 crate::model::{
self, Entry, Gamedata, MonoEntry, Monolith, Schema, Tier, TierSelect, VScriptBinding, one_line,
};
use serde_json::{Map, Value, json};
use std::collections::BTreeMap;
/// 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
@ -177,8 +180,9 @@ pub fn by_id(id: &str) -> Option<Box<dyn GamedataEmitter>> {
}
}
/// Every `--format` id `by_id` accepts — for help text and error messages (keep in sync with `by_id`).
pub const FORMAT_IDS: &[&str] = &[
/// Every gamedata emitter id `by_id` accepts (keep in sync with it), named like its three sibling
/// families. NOT the `--format` list — see [`FORMAT_IDS`], which names CONSUMERS rather than emitters.
pub const GAMEDATA_FORMAT_IDS: &[&str] = &[
"cssharp", "metamod", "modsharp", "swiftly", "plugify", "model",
];
@ -432,10 +436,17 @@ impl SchemaEmitter for CsSdk {
let m = &s.meta;
let mut out = String::new();
out.push_str(&format!(
// The artifact is named by the GAME TOKEN (`cs2`), which the schema does not carry — it knows
// its content key (`csgo`). So the command is spelled generically rather than interpolated
// into something that does not exist.
"// <auto-generated> source2rosetta — {} build {}. Source-2 SchemaSystem field offsets.\n\
// {} classes, {} typed fields, {} enums. Regenerate: source2rosetta-gen --netvars netvars-{}.json --format cs-sdk\n\
// {} classes, {} typed fields, {} enums. Regenerate: source2rosetta-gen --from rosetta-<game>.json --format cs-sdk\n\
namespace Source2.Schema;\n",
m.game_key, m.source_build, s.classes.len(), m.typed, s.enums.len(), m.game_key
m.game_key,
m.source_build,
s.classes.len(),
m.typed,
s.enums.len()
));
// Enums first: a field offset is only half the story, and the C# side wants the type in scope
// before the classes that reference it. Emitted with the engine's own underlying width, so a
@ -754,6 +765,10 @@ pub struct CallShape<'a> {
/// the signatures section at all.
pub vtable: Option<i64>,
pub provenance: &'a [String],
/// What the function is FOR, where anything says — see [`model::FunctionRecord::doc`]. The one
/// field here that is not about the call frame, and the reason a generated call site is readable:
/// an author hovering `CBaseEntity_Kill` sees a sentence rather than a type list.
pub doc: Option<&'a model::Doc>,
}
impl CallShape<'_> {
@ -877,6 +892,7 @@ pub fn callable_shapes(m: &crate::model::AbiManifest) -> Vec<CallShape<'_>> {
lower_bound,
vtable: e.vtable,
provenance: &e.provenance,
doc: e.doc.as_ref(),
});
}
out
@ -913,6 +929,24 @@ const LOWER_BOUND: &str = "ARGUMENTS ARE A LOWER BOUND — the callee reads fewe
passes, so a trailing argument may be ignored; calling through it is safe, the extra register is \
simply unread";
/// What every emitter says about where a description came from. One wording per source, for the same
/// reason as the two constants above: five outputs must not describe one fact differently.
///
/// The marker is load-bearing, not decoration. Most of a shipped artifact's prose is this project's own
/// reading of the build rather than Valve's, and the two carry very different weight — an author acting
/// on a sentence has to know which they are reading. An id this does not recognise is printed VERBATIM
/// rather than quietly presented as Valve's.
fn doc_origin(source: &str) -> String {
match source {
model::Doc::VALVE => "Valve's own text, read from the registry in the binary".into(),
"derived" => {
"source2rosetta's, DERIVED — fixed by the surrounding facts, not Valve's".into()
}
"generated" => "source2rosetta's, GENERATED — a reading of this build, not Valve's".into(),
other => format!("source2rosetta's, source `{other}` — not Valve's"),
}
}
/// Escape text destined for a C# XML doc comment.
///
/// Not cosmetic: the prototypes this carries are full of characters XML reserves. A `CAI_Concept&`
@ -940,6 +974,46 @@ fn source_banner(m: &crate::model::AbiManifest, comment: &str) -> String {
)
}
/// The head of the C# XML doc block above one generated call site — everything above the caveats.
///
/// **The description takes the `<summary>` and the prototype moves down to a `<remarks>`**, because an
/// editor shows the summary first and "what does this thing do" is what an author hovering a
/// `MemoryFunctionVoid<nint, nint>` is asking. With no description the shape is exactly what it was
/// before descriptions existed: the prototype IS the summary, and the block is one line.
///
/// Shared with ModSharp rather than written twice — the two targets differ in what they generate and
/// not in how a function is documented, and two copies of an XML doc block is how they come to disagree.
fn cs_doc_head(c: &CallShape) -> String {
match c.doc {
Some(d) => format!(
" /// <summary>{}</summary>\n \
/// <remarks><c>{}</c></remarks>\n \
/// <remarks>Description: {}.</remarks>",
xml(&d.text),
xml(&c.prototype()),
// Escaped like everything else here: the fixed wordings need none, but an artifact's own
// `source` id ends up in this line verbatim, and one `<` in it is a malformed comment.
xml(&doc_origin(&d.source)),
),
None => format!(" /// <summary><c>{}</c></summary>", xml(&c.prototype())),
}
}
/// The caveat remarks that follow it: what this artifact will not let a call site pretend about itself.
fn cs_doc_caveats(c: &CallShape) -> String {
let mut s = String::new();
if c.ret == Ret::Undeclared {
s.push_str(&format!(
"\n /// <remarks>{UNDECLARED_RETURN} (measured class: <c>{}</c>).</remarks>",
xml(c.ret_source)
));
}
if c.lower_bound {
s.push_str(&format!("\n /// <remarks>{LOWER_BOUND}.</remarks>"));
}
s
}
/// CounterStrikeSharp: a C# source file of `MemoryFunction*` fields bound to the gamedata key.
///
/// It has to be SOURCE, not data: the argument list is the generic parameter list of
@ -979,17 +1053,6 @@ impl AbiEmitter for CsSharpAbi {
"FunctionWithReturn"
}
};
let mut caveat = if c.ret == Ret::Undeclared {
format!(
"\n /// <remarks>{UNDECLARED_RETURN} (measured class: <c>{}</c>).</remarks>",
xml(c.ret_source)
)
} else {
String::new()
};
if c.lower_bound {
caveat.push_str(&format!("\n /// <remarks>{LOWER_BOUND}.</remarks>"));
}
// The two binding forms are not interchangeable. A signature resolves to ONE address and
// can be a static field; a vtable slot is per-object — `VirtualFunction*` takes the
// instance and reads the slot out of that object's own vtable — so it has to be a factory
@ -1026,11 +1089,10 @@ impl AbiEmitter for CsSharpAbi {
None => "signature".to_string(),
};
s.push_str(&format!(
" /// <summary><c>{}</c></summary>\n \
/// <remarks>verified · {how} · {}</remarks>{}\n{}",
xml(&c.prototype()),
"{}\n /// <remarks>verified · {how} · {}</remarks>{}\n{}",
cs_doc_head(&c),
c.provenance.join(", "),
caveat,
cs_doc_caveats(&c),
binding,
));
}
@ -1097,6 +1159,16 @@ impl AbiEmitter for MetamodAbi {
})
.collect::<Vec<_>>()
.join(", ");
// The prose goes ABOVE the identity line, which is where a C++ reader expects a member's
// documentation and where a `//` comment is safe: the text is one line by construction
// (`model::one_line`), so nothing after it can fall out of the comment and into the header.
if let Some(d) = c.doc {
s.push_str(&format!(
"// {}\n// Description: {}.\n",
d.text,
doc_origin(&d.source)
));
}
s.push_str(&format!(
"// {} · verified · {}{}\nusing {}_t = {} (*)({});\n",
c.key,
@ -1141,24 +1213,7 @@ impl AbiEmitter for ModSharpAbi {
s.push_str("using Sharp.Shared;\nusing Sharp.Shared.Attributes;\nusing Sharp.Shared.Calls;\n\nnamespace Sharp.Generated;\n\n");
let shapes = callable_shapes(m);
let doc = |c: &CallShape| {
let mut caveat = if c.ret == Ret::Undeclared {
format!(
"\n /// <remarks>{UNDECLARED_RETURN} (measured class: <c>{}</c>).</remarks>",
xml(c.ret_source)
)
} else {
String::new()
};
if c.lower_bound {
caveat.push_str(&format!("\n /// <remarks>{LOWER_BOUND}.</remarks>"));
}
format!(
" /// <summary><c>{}</c></summary>{}\n",
xml(&c.prototype()),
caveat
)
};
let doc = |c: &CallShape| format!("{}{}\n", cs_doc_head(c), cs_doc_caveats(c));
let ret_cs = |c: &CallShape| match c.ret {
Ret::Void => "void",
Ret::Declared(r) => r.cs(),
@ -1278,6 +1333,11 @@ impl AbiEmitter for SwiftlyAbi {
doc.insert(
c.key.to_string(),
json!({
// The prose, and the id saying whose it is — the machine-readable form of the
// marker the generated-source targets print in words. A consumer rendering this
// into its own docs needs to be able to attribute it.
"description": c.doc.map(|d| d.text.clone()),
"description_source": c.doc.map(|d| d.source.clone()),
"args": c.params.iter().map(|t| t.swiftly()).collect::<String>(),
"ret": match c.ret {
Ret::Void => 'v',
@ -1338,6 +1398,9 @@ impl AbiEmitter for PlugifyAbi {
fns.insert(
c.key.to_string(),
json!({
// As for Swiftly: the text plus whose it is, so a consumer can attribute it.
"description": c.doc.map(|d| d.text.clone()),
"descriptionSource": c.doc.map(|d| d.source.clone()),
"paramTypes": c.params.iter().map(|t| plg(*t)).collect::<Vec<_>>(),
"retType": match c.ret {
Ret::Void => "void",
@ -1361,6 +1424,294 @@ impl AbiEmitter for PlugifyAbi {
}
}
// ============================ VSCRIPT / BINDINGS emitters ============================
/// An emitter over `bindings-<game>.json`'s VScript section.
///
/// A separate family from the gamedata, schema and ABI emitters because it renders a different INPUT:
/// the script-facing surface, grouped by the class that owns it. Both targets here exist to feed the
/// Dota custom-game ecosystem, which already has a toolchain for exactly this shape.
pub trait BindingsEmitter {
fn id(&self) -> &'static str;
/// `schema` supplies the class BASE CHAIN, which the binding registry does not carry. With it, `.d.ts`
/// emits `interface X extends Y` the way the ecosystem's published types do; without it, every
/// interface is flat but still correct — an offline artifact has no schema, and that is the only case
/// where it is absent.
fn render(&self, vscript: &[VScriptBinding], schema: Option<&Schema>) -> String;
}
/// The base class to declare an `extends` against, but ONLY when we also emit an interface for it.
///
/// A dangling `extends` would make the file unusable on its own, and self-contained is the safer default:
/// an author merging this beside the ecosystem's packages loses nothing, while an author using it alone
/// would otherwise get an unresolved reference. The base graph comes from the typed schema, so it is
/// present exactly when the artifact carries a schema, i.e. when it came from a full build.
fn vs_base<'a>(
cls: &str,
schema: Option<&'a Schema>,
emitted: &BTreeMap<&str, Vec<&VScriptBinding>>,
) -> Option<&'a str> {
schema?
.bases
.get(cls)?
.first()
.map(|b| b.name.as_str())
.filter(|b| emitted.contains_key(b))
}
/// Group the VScript bindings by owning class, dropping the ones that have none.
///
/// `class` is live-only (see `VScriptBinding::class`), so an OFFLINE build renders nothing here. That is
/// the honest outcome rather than a bug: both output formats declare members under their interface, and a
/// flat list of function names is not a thing either consumer can use.
fn vscript_by_class(vscript: &[VScriptBinding]) -> BTreeMap<&str, Vec<&VScriptBinding>> {
let mut out: BTreeMap<&str, Vec<&VScriptBinding>> = BTreeMap::new();
for v in vscript {
if let Some(c) = v.class.as_deref() {
out.entry(c).or_default().push(v);
}
}
for v in out.values_mut() {
// By the C++ name as well as the script one: a script name is NOT unique on a class —
// `CBodyComponent::SetMaterialGroup` is two different implementations, and `ConnectOutput` is
// three — so sorting on the script name alone leaves their order decided by the order the
// registry happened to be read in, and the rendered API stops being reproducible.
v.sort_by(|a, b| (&a.name, &a.cpp).cmp(&(&b.name, &b.cpp)));
}
out
}
/// One member per script-facing name, preferring the row Valve documented — see the call site.
fn dedup_by_script_name<'a>(members: &[&'a VScriptBinding]) -> Vec<&'a VScriptBinding> {
let mut out: Vec<&VScriptBinding> = Vec::with_capacity(members.len());
for m in members {
match out.last_mut() {
Some(prev) if prev.name == m.name => {
if prev.description.is_empty() && !m.description.is_empty() {
*prev = m;
}
}
_ => out.push(m),
}
}
out
}
/// Escape text destined for a `/** … */` doc comment.
///
/// Two hazards, both present in shipped data: a `*/` inside the text ENDS the comment early (3 Dota
/// descriptions contain one), and a newline drops the rest of the sentence out of the `*`-continued
/// block the format expects (82 do).
fn jsdoc(s: &str) -> String {
one_line(s).replace("*/", "*\\/")
}
/// The script return type as its TypeScript spelling.
///
/// Lua has one number type, so every numeric `ScriptDataType_t` collapses to `number` — the distinction
/// between `int`, `float` and `uint` is real in the binary and meaningless in the target language, and
/// preserving it would produce declarations that do not typecheck against the ecosystem's own types.
fn vs_ts_type(ret: Option<&str>) -> &'static str {
match ret {
Some("void") | None => "void",
Some("bool") => "boolean",
Some("int") | Some("uint") | Some("float") => "number",
Some("string") => "string",
Some("Vector") => "Vector",
Some("QAngle") => "QAngle",
Some("handle") | Some("ehandle") => "CBaseEntity",
Some("table") => "object",
_ => "any",
}
}
/// `api.json` — the shape ModDota's `dota-data` publishes and its `TypeScriptDeclarations` renders from.
///
/// Emitting THIS rather than declarations directly is deliberate: their toolchain already turns this
/// shape into typed `.d.ts` packages, so matching it means the ecosystem's existing pipeline produces
/// up-to-date output instead of a parallel artifact competing with it.
///
/// Two fields are honestly absent. Parameter lists, because the registry does not carry them — types
/// appear only inside Valve's prose descriptions, inconsistently, in about a fifth of entries. And
/// `available`, because a dedicated server never maps `libclient`, so this derivation cannot see the
/// client side at all and claiming `both` would be an assertion about something never read.
pub struct VScriptApiJson;
impl BindingsEmitter for VScriptApiJson {
fn id(&self) -> &'static str {
"api-json"
}
fn render(&self, vscript: &[VScriptBinding], _schema: Option<&Schema>) -> String {
let mut classes: Vec<Value> = Vec::new();
for (cls, members) in vscript_by_class(vscript) {
let ms: Vec<Value> = members
.iter()
.map(|m| {
let mut o = serde_json::Map::new();
o.insert("kind".into(), json!("function"));
o.insert("name".into(), json!(m.name));
o.insert("available".into(), json!("server"));
if !m.description.is_empty() {
o.insert("description".into(), json!(m.description));
} else if let Some(d) = &m.doc {
// Under a key of OUR name, never theirs. `description` is Valve's field in
// their shape and their toolchain renders it as Valve's word; putting this
// project's reading of the build there would launder it into their published
// types as something Valve wrote.
o.insert("rosetta_description".into(), json!(d.text));
o.insert("rosetta_description_source".into(), json!(d.source));
}
o.insert("returns".into(), json!([m.ret.clone().unwrap_or_default()]));
o.insert("args".into(), json!([]));
// Ours and not theirs: the C++ binding this resolves to, which is the key the same
// function appears under in `gamedata-<game>.json`.
o.insert("cpp".into(), json!(m.cpp));
Value::Object(o)
})
.collect();
classes.push(json!({ "kind": "class", "name": cls, "members": ms }));
}
serde_json::to_string_pretty(&Value::Array(classes)).unwrap_or_default()
}
}
/// `.d.ts` — TypeScript declarations in the style the Dota ecosystem's published types use.
///
/// For authors working against the packages as shipped rather than regenerating them. Members are
/// declared on an interface per class, with Valve's own description as the doc comment — which is the
/// point: hovering a function in an editor shows what Valve wrote about it.
///
/// Parameters are declared `...args: any[]` because the registry does not state them. That is deliberately
/// ugly: it is visible in every signature, so nobody mistakes this for a complete declaration, and it
/// still typechecks. Inventing plausible parameter lists would be worse than saying nothing.
pub struct VScriptDts;
impl BindingsEmitter for VScriptDts {
fn id(&self) -> &'static str {
"dts"
}
fn render(&self, vscript: &[VScriptBinding], schema: Option<&Schema>) -> String {
let mut s = String::new();
s.push_str("/** @noSelfInFile */\n");
s.push_str("// Generated by source2rosetta-gen from rosetta-<game>.json — do not edit.\n");
s.push_str(
"// Return types are Valve's own, read from the script VM's registry. Parameter lists are\n\
// NOT in that registry, so every member takes `...args: any[]`: the arity is unknown, and\n\
// guessing it would produce declarations that lie rather than declarations that abstain.\n\n",
);
let grouped = vscript_by_class(vscript);
for (cls, members) in &grouped {
match vs_base(cls, schema, &grouped) {
Some(base) => s.push_str(&format!("declare interface {cls} extends {base} {{\n")),
None => s.push_str(&format!("declare interface {cls} {{\n")),
}
// One line per SCRIPT-facing name. Where a class registers a name twice — two C++
// implementations behind one script member — an author still sees one member, so declaring
// it twice adds a redundant overload and no information. The row carrying Valve's
// description wins; `api-json` keeps both, because it states the `cpp` that tells them apart.
for m in dedup_by_script_name(members) {
// Valve's own description if the registration carried one — that is the point of this
// format, and it renders bare, the way the ecosystem's published types do. Where Valve
// documents nothing, this project's own reading fills the gap and SAYS SO: a hover
// tooltip is exactly where an unmarked sentence would be taken for Valve's word.
if !m.description.is_empty() {
s.push_str(&format!(" /** {} */\n", jsdoc(&m.description)));
} else if let Some(d) = &m.doc {
s.push_str(&format!(
" /** {}\n * ({}.) */\n",
jsdoc(&d.text),
doc_origin(&d.source)
));
}
s.push_str(&format!(
" {}(...args: any[]): {};\n",
m.name,
vs_ts_type(m.ret.as_deref())
));
}
s.push_str("}\n\n");
}
s
}
}
/// Look up a bindings emitter by its `--format` id.
pub fn bindings_by_id(id: &str) -> Option<Box<dyn BindingsEmitter>> {
match id {
"api-json" => Some(Box::new(VScriptApiJson)),
"dts" => Some(Box::new(VScriptDts)),
_ => None,
}
}
/// Every bindings emitter id `bindings_by_id` accepts (keep in sync with it).
///
/// NOT a `--format` list: both of these are rendered by the single `moddota` format, because they are
/// one consumer's two files — their toolchain reads the JSON and their authors read the declarations.
pub const BINDINGS_FORMAT_IDS: &[&str] = &["api-json", "dts"];
/// Every `--format` `gen` accepts, in the order its help lists them.
///
/// A format names WHO the output is for, not which file it is, so one of them can write several files:
/// a framework gets the gamedata its loader resolves through plus the typed call sites that go through
/// it, and `moddota` gets both of the shapes that ecosystem publishes.
pub const FORMAT_IDS: &[&str] = &[
"cssharp", "metamod", "modsharp", "swiftly", "plugify", "cs-sdk", "netvars", "moddota", "flat",
];
/// Where a format's consumer is known not to run on a game, and the evidence for saying so.
///
/// **A warning rather than a rule.** Each entry is a claim about somebody else's project, read out of
/// their source at one point in time, and third-party projects add games. So `gen` declines by default
/// and takes `--force`: being wrong here costs a flag, never an outcome.
///
/// What earns an entry is a framework that resolves its own binaries through a FIXED game directory,
/// which is a thing its own source states rather than a thing this project infers. Two do:
/// CounterStrikeSharp and Swiftly.
///
/// **ModSharp is deliberately absent**, though it is equally CS2-first in its paths
/// (`../../csgo/steam.inf`): its gamedata carries no game key at all — flat `Addresses` / `VFuncs`,
/// keyed only by platform — so the file rendered here is byte-for-byte the same whatever game the build
/// targets. The two multi-game targets are absent for the opposite reason: Metamod takes Dota 2 as a
/// first-class SDK and Plugify is built per game (`S2SDK_GAME_NAME`), and BOTH key their gamedata by the
/// game directory, which is what `meta.game_key` already carries.
pub const GAME_SUPPORT: &[(&str, &[&str], &str)] = &[
(
"cssharp",
&["csgo"],
"CounterStrikeSharp resolves its binaries out of `<dir>/csgo/bin/` (src/core/memory.h)",
),
(
"swiftly",
&["csgo"],
"Swiftly initialises against the `csgo` game directory (src/core/entrypoint.cpp)",
),
];
/// What is wrong with rendering `format` from a `game_key` artifact, or `None` where nothing is.
///
/// Names the formats that DO cover the game, because the useful thing to tell someone holding a Dota
/// artifact is not that this one is a dead end but which ones are not.
pub fn game_mismatch(format: &str, game_key: &str) -> Option<String> {
let (_, _, why) = GAME_SUPPORT
.iter()
.find(|(f, games, _)| *f == format && !games.contains(&game_key))?;
let covered: Vec<&str> = FORMAT_IDS
.iter()
.copied()
.filter(|f| {
!GAME_SUPPORT
.iter()
.any(|(id, games, _)| id == f && !games.contains(&game_key))
})
.collect();
Some(format!(
"`{format}` is for a consumer that does not run on this game. {why}, and this artifact is \
`{game_key}`.\n Formats that do cover `{game_key}`: {}",
covered.join(", ")
))
}
#[cfg(test)]
mod tests {
use super::*;
@ -1625,6 +1976,138 @@ mod tests {
);
}
#[test]
fn a_description_reaches_every_call_site_format_and_says_whose_it_is() {
let doc = |text: &str, source: &str| crate::model::Doc {
text: text.into(),
source: source.into(),
};
let mut e = abi_entry(&["A*", "int"], true, "void", 2);
e.doc = Some(doc("Kills the entity.", "generated"));
let m = manifest(&[("A::M", e)]);
for out in [CsSharpAbi.render(&m), ModSharpAbi.render(&m)] {
// The prose takes the summary — an editor shows that first, and it is what an author
// hovering a generic type list is asking for — and the prototype keeps a home of its own.
assert!(
out.contains("<summary>Kills the entity.</summary>"),
"{out}"
);
assert!(
out.contains("<remarks><c>A::M(A*, int) -&gt; void</c></remarks>"),
"{out}"
);
assert!(out.contains("GENERATED — a reading of this build, not Valve's"));
}
let mm = MetamodAbi.render(&m);
assert!(mm.contains("// Kills the entity.\n// Description: source2rosetta's, GENERATED"));
assert!(
SwiftlyAbi
.render(&m)
.contains("\"description_source\": \"generated\"")
);
assert!(
PlugifyAbi
.render(&m)
.contains("\"descriptionSource\": \"generated\"")
);
// Valve's own text is attributed to Valve, in the same place, by the same one wording.
let mut e = abi_entry(&["A*", "int"], true, "void", 2);
e.doc = Some(doc("Valve wrote this.", crate::model::Doc::VALVE));
let valve = CsSharpAbi.render(&manifest(&[("A::M", e)]));
assert!(valve.contains("<summary>Valve wrote this.</summary>"));
assert!(
valve.contains("Description: Valve's own text, read from the registry in the binary.")
);
// An id nothing here knows is printed verbatim rather than passed off as Valve's — the same
// rule `flags_raw` follows for a bit whose meaning this build cannot state.
let mut e = abi_entry(&["A*", "int"], true, "void", 2);
e.doc = Some(doc("t", "handwritten"));
let odd = CsSharpAbi.render(&manifest(&[("A::M", e)]));
assert!(odd.contains("source `handwritten` — not Valve's"), "{odd}");
// And with nothing to say, the block is exactly what it was before descriptions existed.
let bare = CsSharpAbi.render(&manifest(&[("A::M", abi_entry(&["A*"], true, "void", 1))]));
assert!(bare.contains("<summary><c>A::M(A*) -&gt; void</c></summary>"));
assert!(!bare.contains("Description:"));
}
#[test]
fn a_description_cannot_break_out_of_the_comment_it_is_printed_in() {
// Real shipped text carries all three hazards: XML metacharacters (~100 descriptions per
// game), a newline (78), and `*/` (3 in Dota).
let mut e = abi_entry(&["A*"], true, "void", 1);
e.doc = Some(crate::model::Doc {
text: crate::model::one_line("a <b> & c\nsecond line */ end"),
source: "generated".into(),
});
let m = manifest(&[("A::M", e)]);
for out in [CsSharpAbi.render(&m), ModSharpAbi.render(&m)] {
assert!(
out.contains("a &lt;b&gt; &amp; c second line */ end"),
"{out}"
);
}
// A `//` comment ends at a newline, so every prose line in the C++ header must be one line.
for line in MetamodAbi.render(&m).lines() {
assert!(
line.starts_with("//") || !line.contains("second line"),
"{line}"
);
}
}
#[test]
fn the_script_api_fills_a_gap_valve_left_and_never_overwrites_one() {
let member = |name: &str, valve: &str, doc: Option<&str>| VScriptBinding {
name: name.into(),
class: Some("CBaseEntity".into()),
cpp: format!("Script_{name}"),
library: "server".into(),
description: valve.into(),
ret: Some("void".into()),
ret_raw: 0,
addr: None,
vtable_slot: None,
doc: doc.map(|t| crate::model::Doc {
text: t.into(),
source: "generated".into(),
}),
};
let vs = vec![
member("Kill", "Valve's own.", Some("ours, and outranked")),
member("Quiet", "", Some("Ours: it does a thing. */")),
member("Silent", "", None),
];
let dts = VScriptDts.render(&vs, None);
assert!(dts.contains("/** Valve's own. */"), "{dts}");
assert!(!dts.contains("outranked"), "{dts}");
// Ours is marked where it lands, because a hover tooltip is exactly where an unattributed
// sentence gets taken for Valve's word. And `*/` inside it must not end the comment.
assert!(
dts.contains("/** Ours: it does a thing. *\\/\n * (source2rosetta's, GENERATED")
);
// A member nothing describes gets no comment at all — an empty one would read as "documented,
// and it says nothing". (Counting the indented form: the file's own `@noSelfInFile` banner is
// a doc comment too.)
assert!(dts.contains(" Silent(...args: any[]): void;"));
assert_eq!(dts.matches(" /**").count(), 2);
// `api.json` is ModDota's shape: `description` is Valve's field, so ours goes under a key of
// our own name rather than being laundered into their published types as Valve's word.
let api: Value = serde_json::from_str(&VScriptApiJson.render(&vs, None)).unwrap();
let ms = &api[0]["members"];
assert_eq!(ms[0]["description"], "Valve's own.");
assert!(ms[0].get("rosetta_description").is_none());
assert!(ms[1].get("description").is_none());
assert_eq!(ms[1]["rosetta_description"], "Ours: it does a thing. */");
assert_eq!(ms[1]["rosetta_description_source"], "generated");
assert!(ms[2].get("rosetta_description").is_none());
}
#[test]
fn a_declared_return_that_cannot_be_represented_drops_the_function() {
// `Vector` by value is a real declaration this cannot express — dropping it is not the same as
@ -1738,10 +2221,60 @@ mod tests {
#[test]
fn every_format_id_resolves_and_renders() {
for id in FORMAT_IDS {
for id in GAMEDATA_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");
}
// Every `--format` has to be reachable through one of the four emitter families, or `gen`
// advertises an id it then rejects. `moddota` is the one that maps to a family rather than to
// an emitter: it is a CONSUMER with two files, which is why the CLI list is not a registry.
for id in FORMAT_IDS {
let known = by_id(id).is_some()
|| schema_by_id(id).is_some()
|| abi_by_id(id).is_some()
|| *id == "moddota"
|| *id == "flat";
assert!(known, "--format {id} resolves to no emitter");
}
for id in BINDINGS_FORMAT_IDS {
assert!(bindings_by_id(id).is_some(), "bindings_by_id({id}) is None");
}
}
#[test]
fn a_consumer_that_does_not_run_on_this_game_is_named_and_so_are_the_ones_that_do() {
// CounterStrikeSharp resolves its own binaries out of `<dir>/csgo/bin/`, so Dota locators in
// its shape describe a file that can never load. Saying which formats DO cover the game is the
// useful half: someone holding a Dota artifact needs a way forward, not just a closed door.
let m = game_mismatch("cssharp", "dota").expect("cssharp does not run on dota");
assert!(m.contains("CounterStrikeSharp"), "{m}");
assert!(
m.contains("metamod") && m.contains("moddota") && m.contains("plugify"),
"{m}"
);
assert!(
!m.contains("swiftly"),
"swiftly does not cover dota either: {m}"
);
assert!(game_mismatch("cssharp", "csgo").is_none());
// Multi-game by evidence: Metamod takes Dota 2 as a first-class SDK, Plugify is built per game,
// and both key their gamedata by the game directory — which is what `game_key` already carries.
for f in ["metamod", "plugify", "moddota", "flat", "cs-sdk", "netvars"] {
assert!(game_mismatch(f, "dota").is_none(), "{f} should cover dota");
}
// ModSharp is CS2-FIRST but not CS2-only in the shape rendered here: its gamedata carries no
// game key at all, so the file is the same whatever game the build targets.
assert!(game_mismatch("modsharp", "dota").is_none());
// Every id the table constrains has to be a format that exists, or the constraint silently
// applies to nothing.
for (id, _, _) in GAME_SUPPORT {
assert!(
FORMAT_IDS.contains(id),
"GAME_SUPPORT names unknown format {id}"
);
}
}
#[test]