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:
GIGABAIT93andClaude Opus 5.5 committed 2026-10-29 10:39:47 +02:00
1 parent 8dfd37428a
commit 82cf8916e2
3 files changed
+149 -8

No files matched your search

+63 -3
View File
@@ -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");
+81 -3
View File
@@ -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| {
+5 -2
View File
@@ -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