//! `source2rosetta-gen` — the standalone generator. Reads the published `rosetta-.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 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::{Path, PathBuf}; #[derive(Parser)] #[command( name = "source2rosetta-gen", version, about = "Render a source2rosetta release into the files your framework reads" )] struct Cli { /// The published `rosetta-.json` — one artifact holding every surface. #[arg(long)] 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 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, /// 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)] 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(); 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(" | "), ), }; 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(()) }