ship one record per function: merge the release set, gen reads it, descriptions as doc comments, gates for what was only claimed; v3.0
This commit is contained in:
parent
71ce34edd2
commit
3410a79b6a
28 changed files with 30596 additions and 955 deletions
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue