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(())
}