mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
horizon-prepare: --complete and --progress json (#3545)
--complete prepares, copies the sounds, then fails unless --check would
pass: one command for resources with nothing missing (without the sounds
step the game is silent, which is easy to miss).
--progress json prints one JSON object a line: {"message": ...} for each
log line, {"fraction": F} as each of the preparation's 18 stages begins
and at the end, {"error": ...} on failure. prepare_with_progress takes
the fraction; prepare keeps its signature, and a test keeps STAGES equal
to the stages steps() opens.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
8dfd37428a
commit
82cf8916e2
3 files changed
+149
-8
No files matched your search
@@ -34,6 +34,12 @@ use std::path::{Path, PathBuf};
|
||||
/// Where progress lines go: the terminal for the binary, the log pane for the launcher.
|
||||
pub type Log<'a> = &'a dyn Fn(&str);
|
||||
|
||||
/// Where the fraction done goes (`--progress json`), from 0 to 1.
|
||||
pub type Progress<'a> = &'a dyn Fn(f32);
|
||||
|
||||
/// The stages of `steps`, each opened by `Run::stage`: what the fraction counts.
|
||||
const STAGES: u32 = 18;
|
||||
|
||||
/// The manifest's `schema`: the prepared files' layout, which the launcher compares.
|
||||
pub const SCHEMA: i64 = 7;
|
||||
|
||||
@@ -324,11 +330,21 @@ struct Run<'a> {
|
||||
previous: Value,
|
||||
origin: Value,
|
||||
log: Log<'a>,
|
||||
progress: Progress<'a>,
|
||||
/// The stages of `steps` begun so far.
|
||||
stages: std::cell::Cell<u32>,
|
||||
}
|
||||
|
||||
const PARITY: &str = "mods/horizon/src/test/java/dev/horizonlab/parity";
|
||||
|
||||
impl Run<'_> {
|
||||
/// Opens the next stage of `steps`: the fraction done is the stages before it.
|
||||
fn stage(&self) {
|
||||
let done = self.stages.get();
|
||||
(self.progress)(done.min(STAGES) as f32 / STAGES as f32);
|
||||
self.stages.set(done + 1);
|
||||
}
|
||||
|
||||
fn bundle(&self) -> Result<&Path, String> {
|
||||
if self.bundles.get().is_none() {
|
||||
self.bundles.set(exporters::classes(self)?).unwrap();
|
||||
@@ -432,15 +448,29 @@ impl Run<'_> {
|
||||
|
||||
/// Prepares the resources; returns their folder (`resources_dir`).
|
||||
pub fn prepare(options: &Options, log: Log) -> Result<PathBuf, String> {
|
||||
prepare_impl(options, log, false)
|
||||
prepare_impl(options, log, &|_| {}, false)
|
||||
}
|
||||
|
||||
/// `prepare`, telling `progress` the fraction done as each stage begins, and 1 at the end.
|
||||
pub fn prepare_with_progress(
|
||||
options: &Options,
|
||||
log: Log,
|
||||
progress: Progress,
|
||||
) -> Result<PathBuf, String> {
|
||||
prepare_impl(options, log, progress, false)
|
||||
}
|
||||
|
||||
/// Release build tool: compile and link-check only Horizon's original exporter classes.
|
||||
pub fn compile_exporters(options: &Options, log: Log) -> Result<PathBuf, String> {
|
||||
prepare_impl(options, log, true)
|
||||
prepare_impl(options, log, &|_| {}, true)
|
||||
}
|
||||
|
||||
fn prepare_impl(options: &Options, log: Log, compile_only: bool) -> Result<PathBuf, String> {
|
||||
fn prepare_impl(
|
||||
options: &Options,
|
||||
log: Log,
|
||||
progress: Progress,
|
||||
compile_only: bool,
|
||||
) -> Result<PathBuf, String> {
|
||||
let root = &std::path::absolute(&options.root)
|
||||
.map_err(|e| format!("{}: {e}", options.root.display()))?;
|
||||
let lock = artifacts::Lock::read(root)?;
|
||||
@@ -517,12 +547,15 @@ fn prepare_impl(options: &Options, log: Log, compile_only: bool) -> Result<PathB
|
||||
previous,
|
||||
origin,
|
||||
log,
|
||||
progress,
|
||||
stages: std::cell::Cell::new(0),
|
||||
};
|
||||
if compile_only {
|
||||
return Ok(run.bundle()?.to_path_buf());
|
||||
}
|
||||
steps(&run, &lock, &oracle, options.minecraft_dir.as_deref())?;
|
||||
check::write_stamp(&resources)?;
|
||||
progress(1.0);
|
||||
log(&format!(
|
||||
"Prepared Horizon native-client resources: {}",
|
||||
resources.display()
|
||||
@@ -553,6 +586,7 @@ fn steps(
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|(k, v)| run.previous(k) == Some(v));
|
||||
run.stage();
|
||||
if !(origin_same
|
||||
&& facts.exists()
|
||||
&& run.previous("facts_sha256") == Some(&run.file_hash("facts.json")?))
|
||||
@@ -574,6 +608,7 @@ fn steps(
|
||||
let helper = run.oracle("VanillaCuboidModelOracle.java");
|
||||
let binding_sources = source_hashes(repo, &[&binding, &helper])?;
|
||||
let model_states = root.join("model-states.json");
|
||||
run.stage();
|
||||
if !(run.previous("model_state_exporters_sha256") == Some(&binding_sources)
|
||||
&& run.same("client_sha256")
|
||||
&& model_states.exists()
|
||||
@@ -591,6 +626,7 @@ fn steps(
|
||||
)?;
|
||||
}
|
||||
|
||||
run.stage();
|
||||
let block_tags = "data/minecraft/tags/block/";
|
||||
let tags = json_resources(&mut open_zip(&run.server)?, block_tags)?;
|
||||
if tags != json_resources(&mut open_zip(&run.client)?, block_tags)? {
|
||||
@@ -613,6 +649,7 @@ fn steps(
|
||||
"attributes.json",
|
||||
"landing-definitions.json",
|
||||
];
|
||||
run.stage();
|
||||
if !(run.previous("motion_exporters_sha256") == Some(&motion_sources)
|
||||
&& run.same("server_sha256")
|
||||
&& run.previous("block_tags_sha256") == Some(&run.file_hash("block-tags.json")?)
|
||||
@@ -651,6 +688,7 @@ fn steps(
|
||||
"damage-catalog.json",
|
||||
"player-float-ids.json",
|
||||
];
|
||||
run.stage();
|
||||
if !(run.previous("health_exporters_sha256") == Some(&health_sources)
|
||||
&& run.same("server_sha256")
|
||||
&& run.files_unchanged("health_files_sha256", &health_files)?)
|
||||
@@ -690,6 +728,7 @@ fn steps(
|
||||
}
|
||||
|
||||
let collision_file = root.join("cached-collision.nbt");
|
||||
run.stage();
|
||||
if !(run.previous("collision_exporters_sha256") == Some(&collision_sources)
|
||||
&& run.same("server_sha256")
|
||||
&& collision_file.exists()
|
||||
@@ -712,6 +751,7 @@ fn steps(
|
||||
|
||||
let render_sources = source_hashes(repo, &[&visibility, &collision, oracle])?;
|
||||
let render_files = ["render-definitions.json", "occlusion.nbt"];
|
||||
run.stage();
|
||||
if !(run.previous("render_exporters_sha256") == Some(&render_sources)
|
||||
&& run.same("server_sha256")
|
||||
&& run.previous("block_tags_sha256") == Some(&run.file_hash("block-tags.json")?)
|
||||
@@ -740,6 +780,7 @@ fn steps(
|
||||
// The playable client needs every client asset (textures, models, fonts, GUI)
|
||||
// and every data-pack resource (worldgen, recipes, loot tables, tags), and
|
||||
// the vanilla pack's icon (`pack.png`) for the pack screen.
|
||||
run.stage();
|
||||
(run.log)("Copying the client's assets and data");
|
||||
let mut indexed = sounds::Assets::load(repo, Some(root), minecraft, run.log);
|
||||
let (version, count) = extract_client_resources(&run.client, root, indexed.as_mut().ok())?;
|
||||
@@ -781,6 +822,7 @@ fn steps(
|
||||
}
|
||||
Ok(hashes)
|
||||
};
|
||||
run.stage();
|
||||
let gameplay_sources = single(
|
||||
"gameplay facts",
|
||||
"gameplay-facts.json",
|
||||
@@ -793,6 +835,7 @@ fn steps(
|
||||
"gameplay-export.log",
|
||||
)?;
|
||||
// Entity dimensions, categories and default attributes for the client's mobs.
|
||||
run.stage();
|
||||
let entity_types_sources = single(
|
||||
"entity types",
|
||||
"entity-types.json",
|
||||
@@ -806,6 +849,7 @@ fn steps(
|
||||
)?;
|
||||
// Block classes, instance configuration and per-state support/signal/fluid
|
||||
// facts for horizon-world's level behaviour (level::Behaviours::load).
|
||||
run.stage();
|
||||
let behaviour_sources = single(
|
||||
"block behaviours",
|
||||
"block-behaviours.json",
|
||||
@@ -818,6 +862,7 @@ fn steps(
|
||||
"behaviour-export.log",
|
||||
)?;
|
||||
|
||||
run.stage();
|
||||
if datagen_reports_missing(root) || !run.same("server_sha256") {
|
||||
// Vanilla's own data generator reports every item's default components.
|
||||
(run.log)("Running vanilla's data generator reports");
|
||||
@@ -855,8 +900,10 @@ fn steps(
|
||||
}
|
||||
|
||||
// Biome parameter tables come from the data generator reports above.
|
||||
run.stage();
|
||||
prepare_worldgen(run)?;
|
||||
|
||||
run.stage();
|
||||
let entity_sources = single(
|
||||
"entity models",
|
||||
"entity-models.json",
|
||||
@@ -870,6 +917,7 @@ fn steps(
|
||||
)?;
|
||||
// Mob AI and animation facts: per-state path types, spawn placements and the
|
||||
// client's keyframe animation definitions (needs the client jar).
|
||||
run.stage();
|
||||
let mob_sources = single(
|
||||
"mob data",
|
||||
"mob-data.json",
|
||||
@@ -887,6 +935,7 @@ fn steps(
|
||||
let command_registries = root.join("command-registries.json");
|
||||
let command_oracle = run.oracle("VanillaCommandParseOracle.java");
|
||||
let command_sources = source_hashes(repo, &[&command_oracle])?;
|
||||
run.stage();
|
||||
if !(run.previous("command_registries_exporters_sha256") == Some(&command_sources)
|
||||
&& command_registries.exists()
|
||||
&& run.same("server_sha256"))
|
||||
@@ -908,6 +957,7 @@ fn steps(
|
||||
// The creative inventory (creative-inventory.json): every tab built by vanilla's
|
||||
// CreativeModeTabs over the vanilla data pack's registries, with the search
|
||||
// tab's tooltip lines and tags.
|
||||
run.stage();
|
||||
let creative_sources = single(
|
||||
"the creative inventory",
|
||||
"creative-inventory.json",
|
||||
@@ -928,6 +978,7 @@ fn steps(
|
||||
.collect::<Result<_, String>>()?,
|
||||
))
|
||||
};
|
||||
run.stage();
|
||||
let mut manifest = vec![("schema".to_string(), Value::Int(SCHEMA.to_string()))];
|
||||
manifest.extend(run.origin.as_object().unwrap().iter().cloned());
|
||||
manifest.extend([
|
||||
@@ -1293,6 +1344,15 @@ mod tests {
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// `--progress json` reaches 1 only at the end: `STAGES` counts the stages `steps` opens.
|
||||
#[test]
|
||||
fn steps_open_every_stage() {
|
||||
let source = include_str!("lib.rs");
|
||||
let body = &source[source.find("\nfn steps(").unwrap()..];
|
||||
let body = &body[..body.find("\n}\n").unwrap()];
|
||||
assert_eq!(body.matches("run.stage();").count() as u32, STAGES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_versions_end_at_the_output_version() {
|
||||
let text = include_str!("../output-versions.txt");
|
||||
|
||||
@@ -16,6 +16,7 @@ const HELP: &str = "\
|
||||
horizon-prepare: prepare Horizon's resources from your own Minecraft Java Edition
|
||||
|
||||
Usage: horizon-prepare [--prepare] [--server-jar PATH] [--minecraft-dir PATH] [--root PATH] [--output PATH]
|
||||
[--complete] [--progress json]
|
||||
horizon-prepare --check [--root PATH] [--output PATH]
|
||||
horizon-prepare compile-exporters [--root PATH]
|
||||
horizon-prepare sounds [--minecraft-dir PATH] [--root PATH] [--output PATH]
|
||||
@@ -35,6 +36,10 @@ Usage: horizon-prepare [--prepare] [--server-jar PATH] [--minecraft-dir PATH] [-
|
||||
the exporters' scratch files stay in <root>/.local
|
||||
--check Exit 0 when the resources are complete and current for this preparer,
|
||||
else 1, printing what each gap needs; runs no Java and reads no game files
|
||||
--complete Also copy the sounds, then fail unless --check would pass: one command
|
||||
for resources the game plays with nothing missing
|
||||
--progress json One JSON object a line on stdout: {\"message\": ...} for each log line,
|
||||
{\"fraction\": F} as the work advances (0 to 1), {\"error\": ...} on failure
|
||||
|
||||
Needs Minecraft played once from the official launcher, and Java of the version
|
||||
research-lock.json pins. Releases use a runtime; development or changed exporters need a JDK.
|
||||
@@ -80,6 +85,8 @@ fn main() -> ExitCode {
|
||||
};
|
||||
let mut root = None;
|
||||
let mut check = false;
|
||||
let mut complete = false;
|
||||
let mut json = false;
|
||||
let mut args = std::env::args_os().skip(1).peekable();
|
||||
let assets = args.next_if(|a| {
|
||||
matches!(
|
||||
@@ -120,6 +127,18 @@ fn main() -> ExitCode {
|
||||
check = true;
|
||||
Ok(())
|
||||
}
|
||||
"--complete" if inline.is_none() && assets.is_none() => {
|
||||
complete = true;
|
||||
Ok(())
|
||||
}
|
||||
"--progress" => match value("--progress") {
|
||||
Ok(v) if v.as_os_str() == "json" => {
|
||||
json = true;
|
||||
Ok(())
|
||||
}
|
||||
Ok(v) => Err(format!("--progress takes json, not {}", v.display())),
|
||||
Err(e) => Err(e),
|
||||
},
|
||||
"-h" | "--help" => {
|
||||
print!("{HELP}");
|
||||
return ExitCode::SUCCESS;
|
||||
@@ -149,11 +168,22 @@ fn main() -> ExitCode {
|
||||
}
|
||||
return check_resources(&options);
|
||||
}
|
||||
let log = |line: &str| {
|
||||
let print = |line: &str| {
|
||||
let mut out = std::io::stdout().lock();
|
||||
let _ = writeln!(out, "{line}");
|
||||
let _ = out.flush();
|
||||
};
|
||||
let log = |line: &str| match json {
|
||||
true => print(&json_line("message", line)),
|
||||
false => print(line),
|
||||
};
|
||||
// Preparing is most of a complete run; the sounds and the check are the rest.
|
||||
let scale = if complete { 0.9 } else { 1.0 };
|
||||
let progress = |fraction: f32| {
|
||||
if json {
|
||||
print(&format!("{{\"fraction\": {:.3}}}", fraction * scale));
|
||||
}
|
||||
};
|
||||
let result = match assets.as_deref().and_then(|a| a.to_str()) {
|
||||
Some("compile-exporters") => horizon_prepare::compile_exporters(&options, &log)
|
||||
.inspect(|dir| log(&dir.display().to_string())),
|
||||
@@ -166,17 +196,65 @@ fn main() -> ExitCode {
|
||||
&log,
|
||||
)
|
||||
}
|
||||
_ => horizon_prepare::prepare(&options, &log),
|
||||
_ => horizon_prepare::prepare_with_progress(&options, &log, &progress).and_then(
|
||||
|resources| match complete {
|
||||
true => complete_resources(&options, resources, &log),
|
||||
false => Ok(resources),
|
||||
},
|
||||
),
|
||||
};
|
||||
match result {
|
||||
Ok(_) => ExitCode::SUCCESS,
|
||||
Ok(_) => {
|
||||
// A plain preparation reported its 1 already.
|
||||
if json && (complete || assets.is_some()) {
|
||||
print("{\"fraction\": 1.000}");
|
||||
}
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => {
|
||||
if json {
|
||||
print(&json_line("error", &e));
|
||||
}
|
||||
eprintln!("error: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `--complete` after preparing: the sounds, then nothing may be missing.
|
||||
fn complete_resources(
|
||||
options: &horizon_prepare::Options,
|
||||
resources: PathBuf,
|
||||
log: horizon_prepare::Log,
|
||||
) -> Result<PathBuf, String> {
|
||||
horizon_prepare::add_assets(
|
||||
"sounds",
|
||||
&options.root,
|
||||
Some(&resources),
|
||||
options.minecraft_dir.as_deref(),
|
||||
log,
|
||||
)?;
|
||||
let lacking = horizon_prepare::check(&options.root, &resources)?;
|
||||
if lacking.is_empty() {
|
||||
return Ok(resources);
|
||||
}
|
||||
for line in &lacking {
|
||||
log(line);
|
||||
}
|
||||
Err(format!(
|
||||
"the resources are not complete: {}",
|
||||
lacking.join("; ")
|
||||
))
|
||||
}
|
||||
|
||||
/// `{"key": "text"}` on one line.
|
||||
fn json_line(key: &str, text: &str) -> String {
|
||||
let mut out = format!("{{\"{key}\": ");
|
||||
horizon_prepare::pyjson::write_string(&mut out, text);
|
||||
out.push('}');
|
||||
out
|
||||
}
|
||||
|
||||
/// `--check`: each gap on its own line on stdout, exit 1 when there is one.
|
||||
fn check_resources(options: &horizon_prepare::Options) -> ExitCode {
|
||||
let resources = horizon_prepare::artifacts::Lock::read(&options.root).map(|lock| {
|
||||
|
||||
@@ -41,8 +41,11 @@ exits 0 when the resources are complete and current for this preparer, and other
|
||||
each gap with the command that fills it and exits 1; it runs no Java and reads no game files.
|
||||
A preparation stamps its resources with the preparer's `OUTPUT_VERSION`
|
||||
(`horizon-prepare.json`); resources prepared before the stamp existed report an unknown
|
||||
version until they are prepared again. Exit codes: 0 done, 1 failed or gaps found, 2 a refused
|
||||
command line.
|
||||
version until they are prepared again. `--complete` prepares, copies the sounds, and fails
|
||||
unless `--check` would then pass: one command for resources with nothing missing.
|
||||
`--progress json` prints one JSON object a line instead of plain text: `{"message": …}` for
|
||||
each log line, `{"fraction": F}` (0 to 1) as each stage begins, `{"error": …}` on failure.
|
||||
Exit codes: 0 done, 1 failed or gaps found, 2 a refused command line.
|
||||
|
||||
```sh
|
||||
cargo run --release -p horizon-prepare -- --prepare --server-jar ~/Downloads/server.jar
|
||||
|
||||
Reference in new issue
Block a user