source2rosetta/crates/source2rosetta-core/src/bin/source2rosetta-gen.rs
Kamal Tufekcic 3410a79b6a
Some checks failed
CI / fuzz (push) Successful in 2m2s
CI / lint (push) Successful in 15s
CI / test (push) Failing after 18s
ship one record per function: merge the release set, gen reads it, descriptions as doc comments, gates for what was only claimed; v3.0
2026-08-02 22:01:36 +03:00

211 lines
9.5 KiB
Rust

//! `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 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-<game>.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(())
}