211 lines
9.5 KiB
Rust
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(())
|
|
}
|