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,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]