Prepare with horizon-prepare: launcher, release folders, messages and docs

The launcher prepares a version in-process when it ships the preparer sources
the launcher was built with, with the version's own horizon-prepare when they
differ, and with the Python script only for versions made before it. Release
folders ship horizon-prepare and its sources; resources are reused between
versions with the same preparer. The game's messages, PLAY.txt, the docs and
release builds name horizon-prepare, and tools/prepare_native_client.py is a
wrapper of it for the development tools.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-10-14 16:29:33 +02:00
1 parent 9fa3a684c6
commit a995fd3b46
22 files changed
+275 -521

No files matched your search

+5 -3
View File
@@ -1,6 +1,6 @@
name: release builds
# Started by hand: gh workflow run release.yml --ref <branch>
# Builds the Windows x64 and Linux x86_64 clients (with horizon-ctl) and uploads the playable folders
# Builds the Windows x64 and Linux x86_64 clients (with horizon-ctl and horizon-prepare) and uploads the playable folders
# as artifacts.
# macOS builds are made on the development machines. Linux is built here, on an older Ubuntu,
# so the binary uses the standard loader and runs on any recent distribution (and on NixOS
@@ -20,13 +20,14 @@ jobs:
# The C runtime linked in, so the game runs without the Visual C++ redistributable.
env:
RUSTFLAGS: -C target-feature=+crt-static
run: cargo +stable build --release --locked -p horizon-client --bin horizon-client --bin horizon-ctl
run: cargo +stable build --release --locked -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare
- name: Only system DLLs
# Vulkan and the rest are loaded at run time; a linked non-system DLL is a
# "DLL not found" dialog on some players' machines.
run: |
python tools/check_imports.py target/release/horizon-client.exe
python tools/check_imports.py target/release/horizon-ctl.exe
python tools/check_imports.py target/release/horizon-prepare.exe
- name: Package
shell: bash
run: packaging/package.sh target/release/horizon-client.exe dist/horizon-windows-x64
@@ -43,11 +44,12 @@ jobs:
- name: ALSA headers
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev
- name: Build the client
run: cargo +stable build --release --locked -p horizon-client --bin horizon-client --bin horizon-ctl
run: cargo +stable build --release --locked -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare
- name: Standard loader and libraries only
run: |
python3 tools/check_imports.py target/release/horizon-client
python3 tools/check_imports.py target/release/horizon-ctl
python3 tools/check_imports.py target/release/horizon-prepare
- name: Package
run: packaging/package.sh target/release/horizon-client dist/horizon-linux-x86_64
- uses: actions/upload-artifact@v4
+1 -1
View File
@@ -140,7 +140,7 @@ Horizon contains no game files and never downloads them. It builds its resources
```sh
git clone https://github.com/ludovic111/horizon.git
cd horizon
python3 tools/prepare_native_client.py --prepare --server-jar ~/Downloads/server.jar # needs JDK 25
cargo run --release -p horizon-prepare -- --prepare --server-jar ~/Downloads/server.jar # needs JDK 25
python3 tools/fetch_sounds.py # optional: sounds from your launcher install
cargo run --release -p horizon-client
```
+3 -3
View File
@@ -243,7 +243,7 @@ impl Options {
match arg.as_str() {
"--help" | "-h" => {
println!(
"Horizon — Minecraft Java Edition 26.3 in Rust\n\n --resources PATH Prepared resources (tools/prepare_native_client.py)\n --render-distance N Chunks, 2..64 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --smoke-report PATH Write a JSON report on exit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --name NAME Player name for multiplayer (default Player)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause."
"Horizon — Minecraft Java Edition 26.3 in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..64 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --smoke-report PATH Write a JSON report on exit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --name NAME Player name for multiplayer (default Player)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause."
);
return Ok(None);
}
@@ -5617,7 +5617,7 @@ impl Game<'_> {
)
} else {
let inner = Arc::new(VanillaTerrain::for_dimension(&self.options.resources.join("worldgen"), res.assets.registry, &res.biomes, seed, dim.id()).map_err(|e| {
format!("{e}\nPrepare the worldgen bundle with: python3 tools/prepare_native_client.py")
format!("{e}\nPrepare the worldgen bundle with the launcher, or: horizon-prepare --prepare")
})?);
self.end.set_locator(Some(inner.clone()));
Streaming::new(
@@ -8692,7 +8692,7 @@ pub fn run() -> Result<()> {
}
Err(e) => {
return Err(format!(
"{e}\nPrepare resources with: python3 tools/prepare_native_client.py --prepare"
"{e}\nPrepare resources with the launcher, or: horizon-prepare --prepare"
)
.into());
}
+1 -1
View File
@@ -154,7 +154,7 @@ impl Game<'_> {
let root = self.options.resources.clone();
let builtin = Builtin::find(&root)
.and_then(|p| Builtin::load(&p).ok())
.ok_or("registries.json not found next to the resources; run the data generator reports (tools/prepare_native_client.py)")?;
.ok_or("registries.json not found next to the resources; run the data generator reports (horizon-prepare)")?;
let lang = Arc::new(horizon_client::net::load_language(&root));
let view = self.settings.render_distance.clamp(2, 32) as i8;
let session = Session::connect(address, name, view, lang.clone());
+1 -1
View File
@@ -62,7 +62,7 @@ impl VanillaTerrain {
if !dir.join("blocks/block-properties.nbt").exists() {
eprintln!(
"Horizon: the worldgen bundle lacks the block behavior exports (worldgen/blocks/*.nbt); \
run python3 tools/prepare_native_client.py to add them"
prepare again (the launcher, or horizon-prepare --prepare) to add them"
);
}
}
+1 -1
View File
@@ -8,7 +8,7 @@
//! [`Behaviours`] holds immutable tables loaded once from the pinned client
//! resources (`block-behaviours.json`, `gameplay-facts.json`,
//! `block-tags.json`, `cached-collision.nbt`; see
//! `tools/prepare_native_client.py`).
//! `crates/horizon-prepare`).
//!
//! # Driving a level
//!
@@ -1,6 +1,6 @@
//! Optional block-behavior registries used by feature placement: physical
//! properties, face support, collision and survival rules. Loaded from the
//! bundle's `blocks/` directory (Java-exported, see prepare_native_client.py).
//! bundle's `blocks/` directory (Java-exported, see crates/horizon-prepare).
use super::generator::GeneratorError;
use crate::{
block_collision::BlockCollisionRegistry,
+9 -4
View File
@@ -14,7 +14,8 @@ missing; [Rust rewrite status](rust-parity.md) has the details.
## Run from a checkout
Use current stable Rust (1.90+), Python 3.10+ and JDK 25. Horizon never
Use current stable Rust (1.90+) and JDK 25 (Python 3.10+ for the sounds and the
development harnesses). Horizon never
downloads the game: resource preparation imports **your own copy** (the 26.3
client from your official launcher install, and the 26.3 server jar you
downloaded yourself; see [Build from source](../README.md#build-from-source)),
@@ -23,8 +24,12 @@ exporters against those jars, and extracts the jar's assets and data into ignore
`.local/horizon-client/26.3/`. Sounds (about 380 MB) are copied from your
launcher's asset store. Prepared resources are private; never commit them.
Preparation is `horizon-prepare` (`crates/horizon-prepare`, Rust): it needs no Python, only
the JDK, for the small exporters that call Mojang's own code. Release folders ship it beside
`horizon-client`, and the launcher prepares with it.
```sh
python3 tools/prepare_native_client.py --prepare --server-jar ~/Downloads/server.jar
cargo run --release -p horizon-prepare -- --prepare --server-jar ~/Downloads/server.jar
python3 tools/fetch_sounds.py
cargo run --release -p horizon-client -- --render-distance 12
```
@@ -205,7 +210,7 @@ attribute. Sneaking invokes ledge backoff before collision resolution. The
complete authoritative player tick remains unfinished.
```sh
python3 tools/prepare_native_client.py
cargo run --release -p horizon-prepare
python3 tools/test_native_scene_collision.py
```
@@ -264,7 +269,7 @@ The [renderer startup check](../benchmarks/rust-native-walking-2026-10-07/startu
also passes after the movement integration.
```sh
python3 tools/prepare_native_client.py
cargo run --release -p horizon-prepare
python3 tools/test_native_scene_motion.py
python3 tools/test_local_input_parity.py
```
+1 -1
View File
@@ -82,7 +82,7 @@ python3 tools/test_section_mesh_parity.py --only fluids
```
It needs the pinned jars (`tools/lab.py prepare`) and prepared client resources
(`tools/prepare_native_client.py --prepare`). Outputs, logs and
(`cargo run --release -p horizon-prepare -- --prepare`). Outputs, logs and
`report.json` go to `target/section-mesh-parity/`; nothing is written under
`.local/`. The fixture is built with `--profile fast` and reports its
meshing time.
+22
View File
@@ -2571,6 +2571,7 @@ dependencies = [
"dirs 5.0.1",
"flate2",
"gpui",
"horizon-prepare",
"horizon-theme",
"image",
"pulldown-cmark",
@@ -2582,6 +2583,16 @@ dependencies = [
"zip",
]
[[package]]
name = "horizon-prepare"
version = "0.1.0"
dependencies = [
"regex",
"sha1",
"sha2 0.10.9",
"zip",
]
[[package]]
name = "horizon-theme"
version = "0.1.0"
@@ -4964,6 +4975,17 @@ dependencies = [
"serde",
]
[[package]]
name = "sha1"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]]
name = "sha1_smol"
version = "1.0.1"
+1
View File
@@ -21,6 +21,7 @@ self-replace = "1"
image = { version = "0.25", default-features = false, features = ["jpeg"] }
clap = { version = "4", features = ["derive"] }
horizon-theme = { path = "../crates/horizon-theme" }
horizon-prepare = { path = "../crates/horizon-prepare" }
[profile.release]
strip = true
+5 -2
View File
@@ -15,8 +15,11 @@ The opening, then Play ([clip](docs/opening.webm)):
- **Versions**: lists the published releases of ludovic111/horizon, installs the build for
this platform into its own folder, and removes it again.
- **Play**: prepares resources as `PLAY.txt` describes (your launcher's 26.3 install, the
26.3 server jar from that version's own json, Python 3.10+ and Java 25), then starts the
client, or a server (`--dedicated --nogui`) in a folder you choose, with a live console.
26.3 server jar from that version's own json, and Java 25), then starts the client, or a
server (`--dedicated --nogui`) in a folder you choose, with a live console. Preparing is
horizon-prepare: linked into the launcher, used in-process when a version ships the same
preparer sources, else that version's own `horizon-prepare`; versions released before it
still prepare with their Python script and need Python 3.10+.
- **Patch notes**: each release's notes, newest first, with a link to GitHub Sponsors. Quiet "Join Discord" and "Website" links sit in the sidebar footer.
Data lives in the platform's data directory (`~/.local/share/horizon-launcher` on Linux,
+144 -27
View File
@@ -380,12 +380,15 @@ pub fn is_prepared(tag: &str) -> bool {
/// Whether the resources `from` prepared are the ones preparing `to` would make. The output
/// depends on the Horizon version, not only on Minecraft's: the manifest records the hashes of
/// the vanilla exporters it was made with (mods/horizon/src/test/java/.../parity), and
/// prepare_native_client.py writes every file and the manifest's `schema`. So both need the
/// same pinned Minecraft jars (research-lock.json), the same prepare_native_client.py and the
/// same exporter sources. The helpers it imports (lab.py and the test_*_parity.py modules)
/// change between releases in how they find and check the player's files, which the output
/// does not depend on; they are not compared.
/// the vanilla exporters it was made with (mods/horizon/src/test/java/.../parity), and the
/// preparer writes every file and the manifest's `schema`. So both need the same pinned
/// Minecraft jars (research-lock.json), the same preparer and the same exporter sources. The
/// preparer is crates/horizon-prepare (its sources ship in the release folder beside the
/// binary) and tools/prepare_native_client.py (the Python preparer of older versions, a wrapper
/// of the binary since); a file only one of the two versions has is a different preparer. The
/// helpers the Python preparer imported (lab.py and the test_*_parity.py modules) change
/// between releases in how they find and check the player's files, which the output does not
/// depend on; they are not compared.
fn resources_compatible(from: &Path, to: &Path, manifest: &serde_json::Value) -> bool {
let pins = |dir: &Path| {
read_json(&dir.join("research-lock.json")).map(|lock| {
@@ -401,10 +404,22 @@ fn resources_compatible(from: &Path, to: &Path, manifest: &serde_json::Value) ->
let Some(fields) = manifest.as_object() else {
return false;
};
let mut sources = vec![
"tools/prepare_native_client.py".to_string(),
"mods/horizon/src/test/java/dev/horizonlab/parity/VanillaHeightmapOracle.java".to_string(),
];
let mut preparer = vec![PathBuf::from("tools/prepare_native_client.py")];
for dir in [from, to] {
preparer.extend(files_under(dir, Path::new(PREPARER_CRATE)));
}
let same_or_absent = |path: &Path| match (fs::read(from.join(path)), fs::read(to.join(path))) {
(Ok(a), Ok(b)) => a == b,
(Err(a), Err(b)) => {
a.kind() == std::io::ErrorKind::NotFound && b.kind() == std::io::ErrorKind::NotFound
}
_ => false,
};
if !preparer.iter().all(|p| same_or_absent(p)) {
return false;
}
let mut sources =
vec!["mods/horizon/src/test/java/dev/horizonlab/parity/VanillaHeightmapOracle.java".to_string()];
for (_, value) in fields
.iter()
.filter(|(key, _)| key.ends_with("_exporters_sha256"))
@@ -425,6 +440,26 @@ fn resources_compatible(from: &Path, to: &Path, manifest: &serde_json::Value) ->
sources.iter().all(|s| same(s))
}
/// Where a version folder keeps the preparer's sources (packaging/package.sh copies them).
const PREPARER_CRATE: &str = "crates/horizon-prepare";
/// The files under `dir/relative`, as paths relative to `dir`.
fn files_under(dir: &Path, relative: &Path) -> Vec<PathBuf> {
let Ok(entries) = fs::read_dir(dir.join(relative)) else {
return Vec::new();
};
let mut files = Vec::new();
for entry in entries.flatten() {
let path = relative.join(entry.file_name());
match entry.file_type() {
Ok(t) if t.is_dir() => files.extend(files_under(dir, &path)),
Ok(_) => files.push(path),
Err(_) => {}
}
}
files
}
fn copy_dir(from: &Path, to: &Path) -> std::io::Result<()> {
fs::create_dir_all(to)?;
for entry in fs::read_dir(from)? {
@@ -500,7 +535,7 @@ fn reuse_prepared_in(versions: &Path) -> Vec<(String, String)> {
.collect()
}
/// The official launcher's game directory, where prepare_native_client.py looks by default.
/// The official launcher's game directory, where horizon-prepare looks by default.
pub fn default_minecraft_dir() -> Option<PathBuf> {
let home = dirs::home_dir()?;
let dir = if cfg!(target_os = "macos") {
@@ -531,13 +566,29 @@ fn python() -> Option<&'static str> {
candidates.iter().copied().find(|p| found(p, "--version"))
}
/// Checks what the preparation needs and builds its command. PLAY.txt describes the same steps:
/// the player's own launcher install, the server jar from that version's own json, Java and Python.
pub fn prepare_command(
tag: &str,
minecraft: &Path,
log: &dyn Fn(String),
) -> Result<Command, String> {
/// How a version's resources are prepared.
pub enum Preparer {
/// horizon-prepare, linked into the launcher: the version ships the same preparer sources.
InProcess(horizon_prepare::Options),
/// The version's own preparer: its horizon-prepare binary when its sources differ from the
/// launcher's (a newer or older release), or the Python script of versions made before it.
Process(Command),
}
/// The version's horizon-prepare binary, when it ships one.
fn preparer_binary(dir: &Path) -> Option<PathBuf> {
let binary = dir.join(if cfg!(windows) {
"horizon-prepare.exe"
} else {
"horizon-prepare"
});
binary.is_file().then_some(binary)
}
/// Checks what the preparation needs and picks how to run it. PLAY.txt describes the same
/// steps: the player's own launcher install, the server jar from that version's own json, and
/// Java (and Python, for versions before horizon-prepare).
pub fn preparer(tag: &str, minecraft: &Path, log: &dyn Fn(String)) -> Result<Preparer, String> {
let version = game_version(tag);
let manifest = minecraft
.join("versions")
@@ -549,12 +600,27 @@ pub fn prepare_command(
minecraft.display()
));
}
let python = python().ok_or("Python 3.10 or newer is needed. Install it and try again.")?;
if !found("java", "-version") {
return Err(
"Java 25 is needed (a JDK, e.g. Eclipse Temurin 25). Install it and try again.".into(),
);
}
let dir = version_dir(tag);
let binary = preparer_binary(&dir);
let python = if binary.is_some() {
// The JDK the version pins, found as horizon-prepare finds it, so a missing one is
// reported before the server jar is downloaded.
let major = read_json(&dir.join("research-lock.json"))
.and_then(|lock| lock["minecraft"]["java_major"].as_i64())
.ok_or("The version's research-lock.json has no java_major")?;
horizon_prepare::java::find(major)?;
None
} else {
let python = python()
.ok_or("This version prepares with Python: Python 3.10 or newer is needed. Install it, or pick a newer version.")?;
if !found("java", "-version") {
return Err(
"Java 25 is needed (a JDK, e.g. Eclipse Temurin 25). Install it and try again."
.into(),
);
}
Some(python)
};
let jar = data_dir()
.join("server-jars")
.join(format!("{version}.jar"));
@@ -577,8 +643,29 @@ pub fn prepare_command(
.and_then(|_| fs::rename(&partial, &jar))
.map_err(|e| e.to_string())?;
}
let mut cmd = Command::new(python);
cmd.current_dir(version_dir(tag))
if let Some(binary) = binary {
if horizon_prepare::sources_sha256(&dir.join(PREPARER_CRATE)).as_deref()
== Some(horizon_prepare::SOURCES_SHA256)
{
return Ok(Preparer::InProcess(horizon_prepare::Options {
root: dir,
import: true,
minecraft_dir: Some(minecraft.to_path_buf()),
server_jar: Some(jar),
}));
}
let mut cmd = Command::new(binary);
cmd.current_dir(&dir)
.arg("--root")
.arg(&dir)
.args(["--prepare", "--server-jar"])
.arg(&jar)
.arg("--minecraft-dir")
.arg(minecraft);
return Ok(Preparer::Process(cmd));
}
let mut cmd = Command::new(python.unwrap());
cmd.current_dir(&dir)
.args([
"-u",
"tools/prepare_native_client.py",
@@ -588,7 +675,20 @@ pub fn prepare_command(
.arg(&jar)
.arg("--minecraft-dir")
.arg(minecraft);
Ok(cmd)
Ok(Preparer::Process(cmd))
}
/// Runs `Preparer::InProcess` on this thread, each progress line to `log`. A panic is an
/// error like any other, so the window never waits on a preparation that died.
pub fn prepare_in_process(
options: &horizon_prepare::Options,
log: &dyn Fn(String),
) -> Result<(), String> {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
horizon_prepare::prepare(options, &|line| log(line.to_string()))
}))
.unwrap_or_else(|_| Err("The preparation stopped unexpectedly".into()))
.map(|_| ())
}
/// Where the game keeps saves and options.txt: `HORIZON_HOME`, else `horizon` in the platform's
@@ -1430,6 +1530,23 @@ mod tests {
assert!(is_prepared("v2026.10.17"));
assert!(!is_prepared("v2026.10.15") && !is_prepared("v2026.10.16"));
assert!(reuse_prepared_in(&versions).is_empty());
// Versions with horizon-prepare: its sources are the preparer, so the same sources
// reuse each other's resources, and a change or a version without them prepares again.
let crate_file = "crates/horizon-prepare/src/lib.rs";
release("v2026.10.18", "26.3");
write("v2026.10.18", crate_file, "rust preparer");
assert_eq!(reuse_prepared(&versions, "v2026.10.18"), None);
prepared("v2026.10.18", "26.3");
release("v2026.10.19", "26.3");
write("v2026.10.19", crate_file, "rust preparer");
assert_eq!(
reuse_prepared(&versions, "v2026.10.19"),
Some("v2026.10.18".into())
);
release("v2026.10.20", "26.3");
write("v2026.10.20", crate_file, "rust preparer, changed");
assert_eq!(reuse_prepared(&versions, "v2026.10.20"), None);
let _ = fs::remove_dir_all(&versions);
}
+6 -2
View File
@@ -205,8 +205,12 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
Cmd::Prepare { minecraft, version } => {
let tag = backend::resolve_version(version.as_deref())?;
let minecraft = backend::minecraft_dir(minecraft)?;
let cmd = backend::prepare_command(&tag, &minecraft, &|line| eprintln!("{line}"))?;
attached(cmd)?;
match backend::preparer(&tag, &minecraft, &|line| eprintln!("{line}"))? {
backend::Preparer::Process(cmd) => attached(cmd)?,
backend::Preparer::InProcess(options) => {
backend::prepare_in_process(&options, &|line| println!("{line}"))?
}
}
println!("{tag} is prepared.");
}
Cmd::Play { version } => attached(backend::client(&backend::resolve_version(
+14 -4
View File
@@ -472,17 +472,27 @@ impl Launcher {
self.prepare_log.clear();
self.prepare_error = None;
self.prepare_log
.push_back("Checking Java, Python and your Minecraft install".into());
.push_back("Checking Java and your Minecraft install".into());
let tx = self.tx.clone();
// The checks may download the server jar, so they run on the thread too.
std::thread::spawn(move || {
let log = |line| {
let _ = tx.send(Msg::Line(Job::Prepare, line));
};
let result = backend::prepare_command(&tag, &minecraft, &log)
.and_then(|cmd| backend::spawn(Job::Prepare, cmd, tx.clone()));
let result = backend::preparer(&tag, &minecraft, &log).and_then(|p| match p {
backend::Preparer::Process(cmd) => {
backend::spawn(Job::Prepare, cmd, tx.clone()).map(|_running| false)
}
backend::Preparer::InProcess(options) => {
backend::prepare_in_process(&options, &log).map(|_| true)
}
});
match result {
Ok(_running) => {}
// A process sends its own Exit when it ends.
Ok(false) => {}
Ok(true) => {
let _ = tx.send(Msg::Exit(Job::Prepare, Ok(())));
}
Err(e) => {
let _ = tx.send(Msg::Exit(Job::Prepare, Err(e)));
}
+20 -5
View File
@@ -24,7 +24,7 @@ fn tools() -> Value {
"inputSchema": { "type": "object", "properties": { "tag": { "type": "string", "description": "A release tag such as v2026.10.13, or \"latest\"." } }, "required": ["tag"] } },
{ "name": "remove_version", "description": "Remove an installed Horizon version and its prepared resources.",
"inputSchema": { "type": "object", "properties": { "tag": { "type": "string" } }, "required": ["tag"] } },
{ "name": "prepare_resources", "description": "Prepare a version's resources from the player's own Minecraft install (needs Minecraft 26.3 played once in the official launcher, Java 25 and Python 3.10+). Can take several minutes; returns the end of the log.",
{ "name": "prepare_resources", "description": "Prepare a version's resources from the player's own Minecraft install (needs Minecraft 26.3 played once in the official launcher and Java 25; versions before horizon-prepare also need Python 3.10+). Can take several minutes; returns the end of the log.",
"inputSchema": { "type": "object", "properties": { "version": version, "minecraft_dir": { "type": "string", "description": "The official launcher's game folder. Default: the launcher's setting, else the platform default." } } } },
{ "name": "launch_client", "description": "Start the Horizon client (a game window on the player's screen). Returns once it has started.",
"inputSchema": { "type": "object", "properties": { "version": version } } },
@@ -95,10 +95,25 @@ impl Session {
let tag = backend::resolve_version(arg_str(args, "version"))?;
let minecraft =
backend::minecraft_dir(arg_str(args, "minecraft_dir").map(PathBuf::from))?;
let cmd = backend::prepare_command(&tag, &minecraft, &|_| {})?;
let run = Logged::start(Job::Prepare, cmd)?;
let result = run.wait();
let tail = run.tail(20).join("\n");
let (result, tail) = match backend::preparer(&tag, &minecraft, &|_| {})? {
backend::Preparer::Process(cmd) => {
let run = Logged::start(Job::Prepare, cmd)?;
let result = run.wait();
(result, run.tail(20).join("\n"))
}
backend::Preparer::InProcess(options) => {
let lines = std::cell::RefCell::new(std::collections::VecDeque::new());
let result = backend::prepare_in_process(&options, &|line| {
let mut lines = lines.borrow_mut();
lines.push_back(line);
if lines.len() > 20 {
lines.pop_front();
}
});
let tail = lines.into_inner().into_iter().collect::<Vec<_>>().join("\n");
(result, tail)
}
};
result
.map(|_| format!("{tag} is prepared.\n{tail}"))
.map_err(|e| format!("{e}\n{tail}"))
+4 -5
View File
@@ -17,11 +17,10 @@ Downloaded this archive directly?
More about Horizon: https://playhorizon.dev
To prepare this folder by hand you need Minecraft Java Edition 26.3, played
once from the official launcher, Python 3.10+ and Java 25 (a JDK). Then,
in this folder:
python3 tools/prepare_native_client.py --prepare --server-jar PATH/TO/server.jar
python3 tools/fetch_sounds.py (optional, for sound)
(Windows: py instead of python3.) The 26.3 server jar is the
once from the official launcher, and Java 25 (a JDK). Then, in this folder:
./horizon-prepare --prepare --server-jar PATH/TO/server.jar
python3 tools/fetch_sounds.py (optional, for sound; needs Python 3.10+)
(Windows: horizon-prepare.exe, and py instead of python3.) The 26.3 server jar is the
downloads.server.url in your launcher's versions/26.3/26.3.json. Add
--minecraft-dir PATH if your launcher keeps the game elsewhere. Then run
./horizon-client (horizon-client.exe on Windows; on macOS the first time:
+16 -11
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env bash
# packaging/package.sh BINARY OUTDIR: a playable Horizon folder with the game
# binary, horizon-ctl (built beside it: cargo build --release -p horizon-client --bins),
# the resource preparation tool and what it compiles, and PLAY.txt.
# binary, horizon-ctl and horizon-prepare (built beside it: cargo build --release
# -p horizon-client --bins -p horizon-prepare), the exporters horizon-prepare compiles,
# its sources (the launcher compares them to its own), and PLAY.txt.
# It holds none of Minecraft's files: players prepare those from their own copy.
set -euo pipefail
bin=$1
@@ -9,14 +10,18 @@ out=$2
repo=$(git -C "$(dirname "$0")" rev-parse --show-toplevel)
rm -rf "$out"
mkdir -p "$out"
git -C "$repo" archive HEAD tools mods/horizon/src/test/java research-lock.json LICENSE | tar -x -C "$out"
git -C "$repo" archive HEAD tools mods/horizon/src/test/java crates/horizon-prepare research-lock.json LICENSE \
| tar -x -C "$out"
cp "$bin" "$out/"
# horizon-ctl, the command line and MCP server that play the game, sits beside the client.
ctl="$(dirname "$bin")/horizon-ctl"
case "$bin" in *.exe) ctl="$ctl.exe" ;; esac
if [ ! -f "$ctl" ]; then
echo "package.sh: $ctl is missing; build it too: cargo build --release -p horizon-client --bins" >&2
exit 1
fi
cp "$ctl" "$out/"
# horizon-ctl, the command line and MCP server that play the game, and horizon-prepare, which
# prepares the resources from the player's own Minecraft, sit beside the client.
for tool in horizon-ctl horizon-prepare; do
path="$(dirname "$bin")/$tool"
case "$bin" in *.exe) path="$path.exe" ;; esac
if [ ! -f "$path" ]; then
echo "package.sh: $path is missing; build it too: cargo build --release -p horizon-client --bins -p horizon-prepare" >&2
exit 1
fi
cp "$path" "$out/"
done
cp "$repo/packaging/PLAY.txt" "$out/PLAY.txt"
+1 -1
View File
@@ -85,7 +85,7 @@ def main():
if not args.binary.is_file():
raise SystemExit(f'missing {args.binary}: run cargo build --release -p horizon-client')
if not (args.resources / 'horizon-resources.json').is_file():
raise SystemExit(f'missing prepared resources in {args.resources}: run tools/prepare_native_client.py --prepare')
raise SystemExit(f'missing prepared resources in {args.resources}: run horizon-prepare --prepare (cargo run --release -p horizon-prepare -- --prepare)')
if not (args.resources / 'assets/minecraft/sounds.json').is_file():
print('warning: no sound assets; run tools/fetch_sounds.py for audio')
+17 -446
View File
@@ -1,457 +1,28 @@
#!/usr/bin/env python3
"""Prepare private pinned resources for Horizon's native development client."""
import argparse
import json
"""Prepare private pinned resources for Horizon's native development client.
The preparation itself is horizon-prepare (crates/horizon-prepare), the Rust port of what this
script did; this wrapper keeps its command line for the development tools that call it. It runs
the horizon-prepare beside this folder (a release folder), else builds it with cargo. Arguments
are passed through: --prepare, --server-jar PATH, --minecraft-dir PATH.
"""
import os
from pathlib import Path
import subprocess
import zipfile
try:
from . import lab, test_rust_parity as base, test_worldgen_parity as worldgen, test_model_baking_parity as baking
except ImportError:
import lab
import test_rust_parity as base
import test_worldgen_parity as worldgen
import test_model_baking_parity as baking
import sys
WORLDGEN_BUNDLES = {
'noises': 'worldgen/noise', 'densities': 'worldgen/density_function', 'rules': 'worldgen/material_rule',
'conditions': 'worldgen/material_condition', 'settings': 'worldgen/noise_settings', 'carvers': 'worldgen/carver',
'biomes': 'worldgen/biome', 'world_presets': 'worldgen/world_preset', 'features': 'worldgen/feature',
'placed_features': 'worldgen/placed_feature', 'state_providers': 'worldgen/block_state_provider',
'biome_tags': 'tags/worldgen/biome', 'feature_tags': 'tags/worldgen/feature', 'block_tags': 'tags/block',
'fluid_tags': 'tags/fluid', 'processor_lists': 'worldgen/processor_list',
'template_pools': 'worldgen/template_pool', 'structures': 'worldgen/structure',
'structure_sets': 'worldgen/structure_set'}
def export_structure_blocks(target, jars, libraries):
"""Run the vanilla exporter of per-state rotate/mirror results and template flags."""
lock = json.loads((lab.ROOT/'research-lock.json').read_text())
source = lab.ROOT/'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaStructureBlockExporter.java'
export = lab.LOCAL/'horizon-client-structures'
classes = export/'classes'
classes.mkdir(parents=True, exist_ok=True)
dependencies = os.pathsep.join(map(str, [jars['server'], *libraries]))
with (export/'export.log').open('w') as log:
subprocess.run(['javac', '--release', str(lock['minecraft']['java_major']), '-proc:none', '-cp', dependencies,
'-d', str(classes), str(source)], check=True, stdout=log, stderr=subprocess.STDOUT, timeout=3600)
subprocess.run(['java', '-Xmx1G', '-cp', str(classes)+os.pathsep+dependencies,
'dev.horizonlab.parity.VanillaStructureBlockExporter', str(target)],
check=True, stdout=log, stderr=subprocess.STDOUT, timeout=3600)
BLOCK_BEHAVIOR_FILES = ['block-properties.nbt', 'cached-collision.nbt', 'cached-support.nbt', 'survival-rules.json',
'survival-extra-definitions.json', 'placement-definitions.json']
def export_block_behavior(export, jars, libraries):
"""Run the vanilla exporters of block properties, support, collision and survival definitions.
Feature placement needs them: without these files the generator skips every placed feature
whose blocks are checked for survival or support (trees, flowers, grass, kelp, ...)."""
try:
from . import test_block_survival_parity as survival
except ImportError:
import test_block_survival_parity as survival
lock = json.loads((lab.ROOT/'research-lock.json').read_text())
tests = lab.ROOT/'mods/horizon/src/test/java'
classes = export/'classes'
classes.mkdir(parents=True, exist_ok=True)
dependencies = os.pathsep.join(map(str, [jars['server'], *libraries]))
with (export/'export.log').open('w') as log:
# The oracle's helper classes are compiled on demand from the source path.
subprocess.run(['javac', '--release', str(lock['minecraft']['java_major']), '-proc:none', '-cp', dependencies,
'-sourcepath', str(tests), '-d', str(classes),
str(tests/'dev/horizonlab/parity/VanillaSimpleBlockOracle.java')],
check=True, stdout=log, stderr=subprocess.STDOUT, timeout=3600)
subprocess.run(['java', '-Xmx1G', '-cp', str(classes)+os.pathsep+dependencies,
'dev.horizonlab.parity.VanillaSimpleBlockOracle', 'export', str(export)],
check=True, stdout=log, stderr=subprocess.STDOUT, timeout=3600)
rules = survival.bind_definitions(*[json.loads((export/name).read_text()) for name in
['survival-classes.json', 'survival-placement-classes.json',
'survival-support-tags.json']])
(export/'survival-rules.json').write_text(json.dumps(rules, separators=(',', ':')))
def prepare_worldgen(root, jars, local, libraries=None):
"""Write the chunk generator's resource bundle to root/worldgen (see GeneratorData::load)."""
out = root / 'worldgen'
out.mkdir(exist_ok=True)
# Structure templates: gzip NBT files under structure/<namespace>/<path>.nbt.
with zipfile.ZipFile(jars['server']) as server:
prefix = 'data/minecraft/structure/'
for name in server.namelist():
if name.startswith(prefix) and name.endswith('.nbt'):
relative = Path(name[len(prefix):])
if relative.is_absolute() or '..' in relative.parts:
raise ValueError('Invalid structure path')
target = out/'structure'/'minecraft'/relative
data = server.read(name)
if not target.exists() or target.read_bytes() != data:
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(data)
with zipfile.ZipFile(jars['server']) as server, zipfile.ZipFile(jars['client']) as client:
for name, path in WORLDGEN_BUNDLES.items():
prefix = f'data/minecraft/{path}/'
def extract(archive):
return {'minecraft:'+n[len(prefix):-5]: archive.read(n).decode('utf-8')
for n in sorted(archive.namelist()) if n.startswith(prefix) and n.endswith('.json')}
resources = extract(server)
if not resources or resources != extract(client):
raise ValueError('Reference worldgen resources disagree: '+path)
(out/(name+'.json')).write_text(json.dumps(resources, separators=(',', ':')), encoding='utf-8')
facts = json.loads((root/'facts.json').read_text())
(out/'block_flags.json').write_text(json.dumps([[s[1], s[2]] for s in facts['states']], separators=(',', ':')))
reports = out/'biome_parameters'
reports.mkdir(exist_ok=True)
presets = reports
for source in [root/'biome-parameters', local/'datagen/out/reports/biome_parameters/minecraft']:
if (source/'overworld.json').exists():
for report in sorted(source.glob('*.json')):
(reports/report.name).write_bytes(report.read_bytes())
break
else:
presets = None
# Block behavior used by feature placement checks (survival, support, collision).
# These come from the Java exporters of the simple-block parity run, or a direct export.
behavior = local/'rust-simple-block-parity'
blocks = out/'blocks'
names = BLOCK_BEHAVIOR_FILES
if libraries is not None and not all((behavior/name).exists() for name in names):
behavior = local/'horizon-client-block-behavior'
export_block_behavior(behavior, jars, libraries)
if all((behavior/name).exists() for name in names):
blocks.mkdir(exist_ok=True)
for name in names:
(blocks/name).write_bytes((behavior/name).read_bytes())
if libraries is not None:
blocks.mkdir(exist_ok=True)
export_structure_blocks(blocks/'structure-blocks.json', jars, libraries)
else:
print('Feature block behavior exports missing; run tools/test_simple_block_parity.py for full decoration')
if presets is None:
# Fall back to the Java-exported quantized tables from the material-stage parity run.
presets = local/'rust-material-stage-parity/presets.json'
if not presets.exists():
raise FileNotFoundError('No multi-noise biome parameter tables; run the data generator reports')
(out/'presets.json').write_bytes(presets.read_bytes())
ROOT = Path(__file__).resolve().parents[1]
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--prepare', action='store_true',
help='Import the pinned game files from your own copy first')
lab.add_game_arguments(parser)
args = parser.parse_args()
if args.prepare:
lab.prepare(args.minecraft_dir, args.server_jar)
lock, jars, libraries = base.verify_artifacts()
root = lab.LOCAL / 'horizon-client' / lock['minecraft']['version']
root.mkdir(parents=True, exist_ok=True)
facts = root / 'facts.json'
manifest = root / 'horizon-resources.json'
oracle = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaHeightmapOracle.java'
origin = {'minecraft': lock['minecraft']['version'],
'client_sha256': lab.digest(jars['client'], 'sha256'),
'server_sha256': lab.digest(jars['server'], 'sha256'),
'exporter_sha256': lab.digest(oracle, 'sha256')}
previous = json.loads(manifest.read_text()) if manifest.exists() else {}
if not (all(previous.get(k) == v for k, v in origin.items()) and facts.exists()
and previous.get('facts_sha256') == lab.digest(facts, 'sha256')):
classes = root / 'classes'
classes.mkdir(exist_ok=True)
dependencies = os.pathsep.join(map(str, [jars['server'], *libraries]))
with (root / 'export.log').open('w') as log:
subprocess.run(['javac', '--release', str(lock['minecraft']['java_major']), '-proc:none',
'-cp', dependencies, '-d', str(classes), str(oracle)], check=True,
stdout=log, stderr=subprocess.STDOUT, timeout=3600)
subprocess.run(['java', '-Xmx512M', '-cp', str(classes) + os.pathsep + dependencies,
'dev.horizonlab.parity.VanillaHeightmapOracle', 'export', str(facts)],
check=True, stdout=log, stderr=subprocess.STDOUT, timeout=3600)
binding = oracle.with_name('VanillaBlockStateBindingOracle.java')
helper = oracle.with_name('VanillaCuboidModelOracle.java')
binding_sources = base.source_hashes([binding, helper])
model_states = root / 'model-states.json'
if not (previous.get('model_state_exporters_sha256') == binding_sources
and previous.get('client_sha256') == origin['client_sha256']
and model_states.exists()
and previous.get('model_states_sha256') == lab.digest(model_states, 'sha256')):
classes = root / 'client-classes'; classes.mkdir(exist_ok=True)
dependencies = os.pathsep.join(map(str, [jars['client'], *baking.client_libraries()]))
with (root / 'model-state-export.log').open('w') as log:
subprocess.run(['javac', '--release', str(lock['minecraft']['java_major']), '-proc:none',
'-cp', dependencies, '-d', str(classes), str(binding), str(helper)],
check=True, stdout=log, stderr=subprocess.STDOUT, timeout=3600)
subprocess.run(['java', '-Xmx512M', '-cp', str(classes) + os.pathsep + dependencies,
'dev.horizonlab.parity.VanillaBlockStateBindingOracle', 'export', str(model_states)],
check=True, stdout=log, stderr=subprocess.STDOUT, timeout=3600)
tags = worldgen.tag_resources(jars['server'])
if tags != worldgen.tag_resources(jars['client']):
raise ValueError('Reference block tags disagree')
lab.write_json(root / 'block-tags.json', tags)
visibility = oracle.with_name('VanillaFaceVisibilityOracle.java')
collision = oracle.with_name('VanillaCollisionOracle.java')
collision_sources = base.source_hashes([collision])
movement = oracle.with_name('VanillaMovementRegistryOracle.java')
attributes = oracle.with_name('VanillaEntityAttributeOracle.java')
landing = oracle.with_name('VanillaBlockLandingOracle.java')
motion_sources = base.source_hashes([movement, attributes, oracle, landing, collision])
motion_files = ['movement-properties.nbt', 'attributes.json', 'landing-definitions.json']
if not (previous.get('motion_exporters_sha256') == motion_sources
and previous.get('server_sha256') == origin['server_sha256']
and previous.get('block_tags_sha256') == lab.digest(root/'block-tags.json', 'sha256')
and all((root/name).exists() and previous.get('motion_files_sha256', {}).get(name)
== lab.digest(root/name, 'sha256') for name in motion_files)):
export = lab.LOCAL/'horizon-client-motion'; export.mkdir(exist_ok=True)
lab.write_json(export/'tags.json', tags)
classes = export/'classes'; classes.mkdir(exist_ok=True)
dependencies = os.pathsep.join(map(str, [jars['server'], *libraries]))
with (export/'export.log').open('w') as log:
subprocess.run(['javac', '--release', str(lock['minecraft']['java_major']), '-proc:none',
'-cp', dependencies, '-d', str(classes), str(movement), str(attributes), str(oracle), str(landing), str(collision)],
check=True, stdout=log, stderr=subprocess.STDOUT, timeout=3600)
for name in ['VanillaBlockLandingOracle', 'VanillaEntityAttributeOracle']:
subprocess.run(['java', '-Xmx768M', '-cp', str(classes)+os.pathsep+dependencies,
'dev.horizonlab.parity.'+name, 'export', str(export)],
check=True, stdout=log, stderr=subprocess.STDOUT, timeout=3600)
(root/'movement-properties.nbt').write_bytes((export/'movement-properties.nbt').read_bytes())
(root/'attributes.json').write_bytes((export/'catalog.json').read_bytes())
(root/'landing-definitions.json').write_bytes((export/'landing-definitions.json').read_bytes())
damage = oracle.with_name('VanillaDamageSourceOracle.java')
health = oracle.with_name('VanillaPlayerHealthOracle.java')
health_sources = base.source_hashes([damage, health, attributes])
health_files = ['damage-types.json', 'damage-tags.json', 'damage-catalog.json', 'player-float-ids.json']
if not (previous.get('health_exporters_sha256') == health_sources
and previous.get('server_sha256') == origin['server_sha256']
and all((root/name).exists() and previous.get('health_files_sha256', {}).get(name)
== lab.digest(root/name, 'sha256') for name in health_files)):
export = lab.LOCAL/'horizon-client-health'; export.mkdir(exist_ok=True)
with zipfile.ZipFile(jars['server']) as archive, zipfile.ZipFile(jars['client']) as client_archive:
for file, prefix in [('types.json', 'data/minecraft/damage_type/'), ('tags.json', 'data/minecraft/tags/damage_type/')]:
def extract(source):
return {'minecraft:'+n[len(prefix):-5]: source.read(n).decode('utf-8')
for n in sorted(source.namelist()) if n.startswith(prefix) and n.endswith('.json')}
resources = extract(archive)
if resources != extract(client_archive): raise ValueError('Reference damage resources disagree')
lab.write_json(export/file, resources)
classes = export/'classes'; classes.mkdir(exist_ok=True)
dependencies = os.pathsep.join(map(str, [jars['server'], *libraries]))
with (export/'export.log').open('w') as log:
subprocess.run(['javac', '--release', str(lock['minecraft']['java_major']), '-proc:none',
'-cp', dependencies, '-d', str(classes), str(damage), str(health), str(attributes)],
check=True, stdout=log, stderr=subprocess.STDOUT, timeout=3600)
for name in ['VanillaPlayerHealthOracle', 'VanillaDamageSourceOracle']:
subprocess.run(['java', '-Xmx768M', '-cp', str(classes)+os.pathsep+dependencies,
'dev.horizonlab.parity.'+name, 'export', str(export)],
check=True, stdout=log, stderr=subprocess.STDOUT, timeout=3600)
for source, target in [('types.json','damage-types.json'), ('tags.json','damage-tags.json'),
('catalog.json','damage-catalog.json'), ('ids.json','player-float-ids.json')]:
(root/target).write_bytes((export/source).read_bytes())
collision_file = root / 'cached-collision.nbt'
if not (previous.get('collision_exporters_sha256') == collision_sources
and previous.get('server_sha256') == origin['server_sha256']
and collision_file.exists()
and previous.get('collision_sha256') == lab.digest(collision_file, 'sha256')):
export = lab.LOCAL/'horizon-client-collision'; export.mkdir(exist_ok=True)
classes = export/'classes'; classes.mkdir(exist_ok=True)
dependencies = os.pathsep.join(map(str, [jars['server'], *libraries]))
with (export/'export.log').open('w') as log:
subprocess.run(['javac', '--release', str(lock['minecraft']['java_major']), '-proc:none',
'-cp', dependencies, '-d', str(classes), str(collision)],
check=True, stdout=log, stderr=subprocess.STDOUT, timeout=3600)
subprocess.run(['java', '-Xmx768M', '-cp', str(classes)+os.pathsep+dependencies,
'dev.horizonlab.parity.VanillaCollisionOracle', 'generate', str(export)],
check=True, stdout=log, stderr=subprocess.STDOUT, timeout=3600)
collision_file.write_bytes((export/'cached-collision.nbt').read_bytes())
render_sources = base.source_hashes([visibility, collision, oracle])
render_files = ['render-definitions.json', 'occlusion.nbt']
tag_hash = lab.digest(root/'block-tags.json', 'sha256')
if not (previous.get('render_exporters_sha256') == render_sources
and previous.get('server_sha256') == origin['server_sha256']
and previous.get('block_tags_sha256') == tag_hash
and all((root/name).exists() and previous.get('render_files_sha256', {}).get(name)
== lab.digest(root/name, 'sha256') for name in render_files)):
export = lab.LOCAL/'horizon-client-render'; export.mkdir(exist_ok=True)
lab.write_json(export/'tags.json', tags)
classes = export/'classes'; classes.mkdir(exist_ok=True)
dependencies = os.pathsep.join(map(str, [jars['server'], *libraries]))
with (export/'export.log').open('w') as log:
subprocess.run(['javac', '--release', str(lock['minecraft']['java_major']), '-proc:none',
'-cp', dependencies, '-d', str(classes), str(visibility), str(collision), str(oracle)],
check=True, stdout=log, stderr=subprocess.STDOUT, timeout=3600)
subprocess.run(['java', '-Xmx768M', '-cp', str(classes)+os.pathsep+dependencies,
'dev.horizonlab.parity.VanillaFaceVisibilityOracle', 'export', str(export)],
check=True, stdout=log, stderr=subprocess.STDOUT, timeout=3600)
(root/'render-definitions.json').write_bytes((export/'definitions.json').read_bytes())
(root/'occlusion.nbt').write_bytes((export/'occlusion.nbt').read_bytes())
count = 0
# The playable client needs every client asset (textures, models, fonts, GUI)
# and every data-pack resource (worldgen, recipes, loot tables, tags).
prefixes = ('assets/', 'data/')
with zipfile.ZipFile(jars['client']) as archive:
version = json.loads(archive.read('version.json'))
for name in archive.namelist():
if not name.startswith(prefixes) or name.endswith('/'):
continue
relative = Path(name)
if relative.is_absolute() or '..' in relative.parts:
raise ValueError('Invalid resource path')
target = root / relative
data = archive.read(name)
if not target.exists() or target.read_bytes() != data:
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(data)
count += 1
gameplay = root / 'gameplay-facts.json'
gameplay_oracle = oracle.with_name('VanillaGameplayFactsExporter.java')
gameplay_sources = base.source_hashes([gameplay_oracle])
if not (previous.get('gameplay_exporters_sha256') == gameplay_sources and gameplay.exists()
and previous.get('server_sha256') == origin['server_sha256']):
classes = lab.LOCAL / 'gameplay-facts' / 'classes'; classes.mkdir(parents=True, exist_ok=True)
dependencies = os.pathsep.join(map(str, [jars['server'], *libraries]))
with (root / 'gameplay-export.log').open('w') as log:
subprocess.run(['javac', '--release', str(lock['minecraft']['java_major']), '-proc:none',
'-cp', dependencies, '-d', str(classes), str(gameplay_oracle)],
check=True, stdout=log, stderr=subprocess.STDOUT, timeout=3600)
subprocess.run(['java', '-Xmx1G', '-cp', str(classes) + os.pathsep + dependencies,
'dev.horizonlab.parity.VanillaGameplayFactsExporter', str(gameplay)],
check=True, stdout=log, stderr=subprocess.STDOUT, timeout=3600)
# Entity dimensions, categories and default attributes for the client's mobs.
entity_types = root / 'entity-types.json'
entity_types_oracle = oracle.with_name('VanillaEntityTypesExporter.java')
entity_types_sources = base.source_hashes([entity_types_oracle])
if not (previous.get('entity_types_exporters_sha256') == entity_types_sources and entity_types.exists()
and previous.get('server_sha256') == origin['server_sha256']):
classes = lab.LOCAL / 'entity-types' / 'classes'; classes.mkdir(parents=True, exist_ok=True)
dependencies = os.pathsep.join(map(str, [jars['server'], *libraries]))
with (root / 'entity-types-export.log').open('w') as log:
subprocess.run(['javac', '--release', str(lock['minecraft']['java_major']), '-proc:none',
'-cp', dependencies, '-d', str(classes), str(entity_types_oracle)],
check=True, stdout=log, stderr=subprocess.STDOUT, timeout=3600)
subprocess.run(['java', '-Xmx1G', '-cp', str(classes) + os.pathsep + dependencies,
'dev.horizonlab.parity.VanillaEntityTypesExporter', str(entity_types)],
check=True, stdout=log, stderr=subprocess.STDOUT, timeout=3600)
# Block classes, instance configuration and per-state support/signal/fluid
# facts for horizon-world's level behaviour (level::Behaviours::load).
behaviours = root / 'block-behaviours.json'
behaviour_oracle = oracle.with_name('VanillaBlockBehaviourExporter.java')
behaviour_sources = base.source_hashes([behaviour_oracle])
if not (previous.get('behaviour_exporters_sha256') == behaviour_sources and behaviours.exists()
and previous.get('server_sha256') == origin['server_sha256']):
classes = lab.LOCAL / 'block-behaviours' / 'classes'; classes.mkdir(parents=True, exist_ok=True)
dependencies = os.pathsep.join(map(str, [jars['server'], *libraries]))
with (root / 'behaviour-export.log').open('w') as log:
subprocess.run(['javac', '--release', str(lock['minecraft']['java_major']), '-proc:none',
'-cp', dependencies, '-d', str(classes), str(behaviour_oracle)],
check=True, stdout=log, stderr=subprocess.STDOUT, timeout=3600)
subprocess.run(['java', '-Xmx1G', '-cp', str(classes) + os.pathsep + dependencies,
'dev.horizonlab.parity.VanillaBlockBehaviourExporter', str(behaviours)],
check=True, stdout=log, stderr=subprocess.STDOUT, timeout=3600)
items = root / 'items.json'
if not items.exists() or previous.get('server_sha256') != origin['server_sha256']:
# Vanilla's own data generator reports every item's default components.
datagen = lab.LOCAL / 'datagen'; datagen.mkdir(exist_ok=True)
with (datagen / 'datagen.log').open('w') as log:
subprocess.run(['java', '-DbundlerMainClass=net.minecraft.data.Main', '-jar',
str(jars['server'].with_name('server.jar')), '--reports', '--output', 'out'],
cwd=datagen, check=True, stdout=log, stderr=subprocess.STDOUT, timeout=3600)
reports = datagen / 'out' / 'reports'
components = {f'minecraft:{f.stem}': json.loads(f.read_text())['components']
for f in sorted((reports / 'minecraft' / 'components' / 'item').glob('*.json'))}
items.write_text(json.dumps(components))
(root / 'biome-parameters').mkdir(exist_ok=True)
for f in (reports / 'biome_parameters' / 'minecraft').glob('*.json'):
(root / 'biome-parameters' / f.name).write_bytes(f.read_bytes())
# Biome parameter tables come from the data generator reports above.
prepare_worldgen(root, jars, lab.LOCAL, libraries)
entity_models = root / 'entity-models.json'
entity_oracle = oracle.with_name('VanillaEntityModelExporter.java')
entity_sources = base.source_hashes([entity_oracle])
if not (previous.get('entity_model_exporters_sha256') == entity_sources and entity_models.exists()
and previous.get('client_sha256') == origin['client_sha256']):
classes = lab.LOCAL / 'entity-models' / 'classes'; classes.mkdir(parents=True, exist_ok=True)
dependencies = os.pathsep.join(map(str, [jars['client'], *baking.client_libraries()]))
with (root / 'entity-model-export.log').open('w') as log:
subprocess.run(['javac', '--release', str(lock['minecraft']['java_major']), '-proc:none',
'-cp', dependencies, '-d', str(classes), str(entity_oracle)],
check=True, stdout=log, stderr=subprocess.STDOUT, timeout=3600)
subprocess.run(['java', '-Xmx1G', '-cp', str(classes) + os.pathsep + dependencies,
'dev.horizonlab.parity.VanillaEntityModelExporter', str(entity_models)],
check=True, stdout=log, stderr=subprocess.STDOUT, timeout=3600)
# Mob AI and animation facts: per-state path types, spawn placements and the
# client's keyframe animation definitions (needs the client jar).
mob_data = root / 'mob-data.json'
mob_oracle = oracle.with_name('VanillaMobDataExporter.java')
mob_sources = base.source_hashes([mob_oracle])
if not (previous.get('mob_data_exporters_sha256') == mob_sources and mob_data.exists()
and previous.get('client_sha256') == origin['client_sha256']):
classes = lab.LOCAL / 'mob-data' / 'classes'; classes.mkdir(parents=True, exist_ok=True)
dependencies = os.pathsep.join(map(str, [jars['client'], *baking.client_libraries()]))
with (root / 'mob-data-export.log').open('w') as log:
subprocess.run(['javac', '--release', str(lock['minecraft']['java_major']), '-proc:none',
'-cp', dependencies, '-d', str(classes), str(mob_oracle)],
check=True, stdout=log, stderr=subprocess.STDOUT, timeout=3600)
subprocess.run(['java', '-Xmx1G', '-cp', str(classes) + os.pathsep + dependencies,
'dev.horizonlab.parity.VanillaMobDataExporter', str(mob_data), str(jars['client'])],
check=True, stdout=log, stderr=subprocess.STDOUT, timeout=3600)
# Vanilla's built-in registries for in-game command parsing (mob effects,
# data component types, dialogs, block entity and tag members).
command_registries = root / 'command-registries.json'
command_oracle = oracle.with_name('VanillaCommandParseOracle.java')
command_sources = base.source_hashes([command_oracle])
if not (previous.get('command_registries_exporters_sha256') == command_sources and command_registries.exists()
and previous.get('server_sha256') == origin['server_sha256']):
export = lab.LOCAL / 'command-registries'; classes = export / 'classes'; classes.mkdir(parents=True, exist_ok=True)
dependencies = os.pathsep.join(map(str, [jars['server'], *libraries]))
with (root / 'command-registries-export.log').open('w') as log:
subprocess.run(['javac', '--release', str(lock['minecraft']['java_major']), '-proc:none',
'-cp', dependencies, '-d', str(classes), str(command_oracle)],
check=True, stdout=log, stderr=subprocess.STDOUT, timeout=3600)
subprocess.run(['java', '-Xmx1G', '-cp', str(classes) + os.pathsep + dependencies,
'dev.horizonlab.parity.VanillaCommandParseOracle', 'registries', str(export)],
check=True, stdout=log, stderr=subprocess.STDOUT, timeout=3600)
command_registries.write_bytes((export / 'registries.json').read_bytes())
# 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.
creative = root / 'creative-inventory.json'
creative_oracles = [oracle.with_name('VanillaCreativeTabsExporter.java'), oracle.with_name('ParityData.java')]
creative_sources = base.source_hashes(creative_oracles)
if not (previous.get('creative_exporters_sha256') == creative_sources and creative.exists()
and previous.get('server_sha256') == origin['server_sha256']):
classes = lab.LOCAL / 'creative-inventory' / 'classes'; classes.mkdir(parents=True, exist_ok=True)
dependencies = os.pathsep.join(map(str, [jars['server'], *libraries]))
with (root / 'creative-export.log').open('w') as log:
subprocess.run(['javac', '--release', str(lock['minecraft']['java_major']), '-proc:none',
'-cp', dependencies, '-d', str(classes), *map(str, creative_oracles)],
check=True, stdout=log, stderr=subprocess.STDOUT, timeout=3600)
subprocess.run(['java', '-Xmx2G', '-cp', str(classes) + os.pathsep + dependencies,
'dev.horizonlab.parity.VanillaCreativeTabsExporter', str(creative)],
check=True, stdout=log, stderr=subprocess.STDOUT, timeout=3600)
lab.write_json(manifest, {'schema': 7, **origin,
'health_exporters_sha256': health_sources,
'health_files_sha256': {name: lab.digest(root/name, 'sha256') for name in health_files},
'motion_exporters_sha256': motion_sources,
'motion_files_sha256': {name: lab.digest(root/name, 'sha256') for name in motion_files},
'collision_exporters_sha256': collision_sources,
'collision_sha256': lab.digest(collision_file, 'sha256'),
'render_exporters_sha256': render_sources,
'render_files_sha256': {name: lab.digest(root/name, 'sha256') for name in render_files},
'model_state_exporters_sha256': binding_sources,
'gameplay_exporters_sha256': gameplay_sources,
'entity_model_exporters_sha256': entity_sources,
'creative_exporters_sha256': creative_sources,
'mob_data_exporters_sha256': mob_sources,
'entity_types_exporters_sha256': entity_types_sources,
'behaviour_exporters_sha256': behaviour_sources,
'command_registries_exporters_sha256': command_sources,
'model_states_sha256': lab.digest(model_states, 'sha256'), 'data_version': version['world_version'],
'facts_sha256': lab.digest(facts, 'sha256'),
'block_tags_sha256': lab.digest(root / 'block-tags.json', 'sha256'), 'resource_files': count})
print(f'Prepared Horizon native-client resources: {root}')
arguments = ['--root', str(ROOT), *sys.argv[1:]]
beside = ROOT / ('horizon-prepare.exe' if os.name == 'nt' else 'horizon-prepare')
if beside.is_file():
command = [str(beside), *arguments]
else:
command = ['cargo', 'run', '--quiet', '--release', '-p', 'horizon-prepare', '--', *arguments]
return subprocess.call(command, cwd=ROOT)
if __name__ == '__main__':
main()
sys.exit(main())
+1 -1
View File
@@ -45,7 +45,7 @@ def target(game_dir, name):
"""REA admits JADX targets by their .apk name; JADX reads the class files either way."""
source = game_dir / "jars" / f"{name}.jar"
if not source.exists():
raise SystemExit(f"{source} is missing; run python3 tools/prepare_native_client.py --prepare")
raise SystemExit(f"{source} is missing; run cargo run --release -p horizon-prepare -- --prepare")
copy = lab.LOCAL / "rea" / f"{name}-{lab.digest(source, 'sha256')[:16]}.apk"
if not copy.exists():
copy.parent.mkdir(parents=True, exist_ok=True)
+1 -1
View File
@@ -2,7 +2,7 @@
"""Compare the creative inventory's tabs and search with the vanilla client's.
The Java oracle (VanillaCreativeSearchOracle) writes the creative-inventory.json
that tools/prepare_native_client.py exports (VanillaCreativeTabsExporter: every
that horizon-prepare exports (VanillaCreativeTabsExporter: every
tab built by vanilla's CreativeModeTabs over the vanilla data pack's
registries), then what the vanilla client shows: every tab's display items
and, for each search query, CreativeModeInventoryScreen.refreshSearchResults'