horizon-prepare sounds: the sound copy in Rust, run by the launcher

tools/fetch_sounds.py (and the lab.py functions it used) becomes
`horizon-prepare sounds` (horizon_prepare::sounds): the pinned asset index,
sounds.json and every .ogg from the player's own launcher install, each SHA-1
checked, into .local/horizon-client/<version>. The launcher runs it as part of
Prepare (in-process, the version's own binary when its sources differ and it
knows the subcommand, in-process for older versions), and a launcher install
without the assets only logs that the game will be silent. Player docs no
longer mention Python for sound.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-10-14 18:10:34 +02:00
1 parent b2462d54c4
commit 8355a6ff14
15 files changed
+408 -65

No files matched your search

+1 -1
View File
@@ -141,7 +141,7 @@ Horizon contains no game files and never downloads them. It builds its resources
git clone https://github.com/ludovic111/horizon.git
cd horizon
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-prepare -- sounds # optional: sounds from your launcher install
cargo run --release -p horizon-client
```
+8 -3
View File
@@ -22,6 +22,11 @@ fn hint(text: &str, version: &str) -> String {
text.replace("{version}", version)
}
/// What to do when the player's launcher install lacks the pinned version's files.
pub(crate) fn launcher_hint(version: &str) -> String {
hint(LAUNCHER_HINT, version)
}
/// research-lock.json and the folder the pinned version's files go to.
pub struct Lock {
pub json: Value,
@@ -120,7 +125,7 @@ pub fn minecraft_dir(chosen: Option<&Path>) -> PathBuf {
/// Copy one file from the player's install, checked like a download; an existing verified
/// copy is kept.
fn import_copy(
pub(crate) fn import_copy(
source: &Path,
destination: &Path,
checksum: &str,
@@ -156,7 +161,7 @@ fn import_copy(
/// The pinned version JSON from the launcher's versions/ directory. The launcher's copy wins
/// over the cached one so the manifest matches the asset index installed alongside it.
fn import_manifest(lock: &Lock, minecraft: &Path) -> Result<Value, String> {
pub(crate) fn import_manifest(lock: &Lock, minecraft: &Path) -> Result<Value, String> {
let version = &lock.version;
let destination = lock.directory.join("manifest.json");
let source = minecraft
@@ -355,7 +360,7 @@ fn client_library_artifacts(manifest: &Value) -> Result<Vec<&Value>, String> {
}
/// A relative path with only normal components and forward slashes, from a manifest or bundle.
fn safe_relative(path: &str) -> Option<PathBuf> {
pub(crate) fn safe_relative(path: &str) -> Option<PathBuf> {
if path.contains('\\') || path.starts_with('/') {
return None;
}
+3
View File
@@ -14,8 +14,11 @@ pub mod artifacts;
mod inventory;
pub mod java;
pub mod pyjson;
mod sounds;
mod survival;
pub use sounds::sounds;
use pyjson::Value;
use sha2::Digest;
use std::fs;
+16 -2
View File
@@ -9,6 +9,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]
horizon-prepare sounds [--minecraft-dir PATH] [--root PATH]
--prepare Import the pinned game files from your own copy first
--server-jar PATH The pinned server jar you downloaded yourself
@@ -21,6 +22,10 @@ Usage: horizon-prepare [--prepare] [--server-jar PATH] [--minecraft-dir PATH] [-
Needs Minecraft played once from the official launcher, and Java (a JDK) of the version
research-lock.json pins: JAVA_HOME, else javac and java on the PATH.
The resources go to <root>/.local/horizon-client/<version>; pass that to horizon-client --resources.
horizon-prepare sounds copies the pinned version's sounds (about 380 MB) from the official
launcher's asset store into the same folder, checking every file. It is optional: without it the
game is silent. Nothing is downloaded; it needs the version played once from the official launcher.
";
/// The folder with research-lock.json: beside the executable in a release folder, else the
@@ -46,7 +51,8 @@ fn main() -> ExitCode {
server_jar: None,
};
let mut root = None;
let mut args = std::env::args_os().skip(1);
let mut args = std::env::args_os().skip(1).peekable();
let sounds = args.next_if(|a| a == "sounds").is_some();
while let Some(arg) = args.next() {
let text = arg.to_string_lossy().into_owned();
let (flag, inline) = match text.split_once('=') {
@@ -65,6 +71,9 @@ fn main() -> ExitCode {
}
};
let parsed = match flag.as_str() {
"--prepare" | "--server-jar" if sounds => {
Err(format!("{flag} is for preparing, not for sounds"))
}
"--prepare" if inline.is_none() => {
options.import = true;
Ok(())
@@ -99,7 +108,12 @@ fn main() -> ExitCode {
let _ = writeln!(out, "{line}");
let _ = out.flush();
};
match horizon_prepare::prepare(&options, &log) {
let result = if sounds {
horizon_prepare::sounds(&options.root, options.minecraft_dir.as_deref(), &log)
} else {
horizon_prepare::prepare(&options, &log)
};
match result {
Ok(_) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e}");
+299
View File
@@ -0,0 +1,299 @@
//! The pinned version's sounds (sounds.json and the .ogg files) from the player's own official
//! launcher install, each object checked against the asset index's SHA-1, copied into
//! `.local/horizon-client/<version>/assets` where the game reads them (tools/fetch_sounds.py and
//! the tools/lab.py functions it called). Nothing is downloaded.
use crate::artifacts::{self, Lock};
use crate::pyjson::Value;
use crate::{digest, read_json, Log};
use std::fs;
use std::path::{Path, PathBuf};
/// The launcher's asset directory and the pinned version's verified asset index
/// (tools/lab.py `launcher_assets`).
fn launcher_assets(manifest: &Value, minecraft: &Path) -> Result<(PathBuf, PathBuf), String> {
let assets = minecraft.join("assets");
let info = manifest
.get("assetIndex")
.ok_or("The version JSON has no assetIndex")?;
let field = |key: &str| {
info.get(key)
.and_then(Value::as_str)
.ok_or_else(|| format!("The version JSON's assetIndex has no {key}"))
};
let (id, sha1) = (field("id")?, field("sha1")?);
if artifacts::safe_relative(id).is_none_or(|p| p.components().count() != 1) {
return Err(format!("Unsafe asset index id {id:?}"));
}
let index = assets.join("indexes").join(format!("{id}.json"));
if !index.is_file() {
let version = manifest.get("id").and_then(Value::as_str).unwrap_or("");
return Err(format!(
"Missing {}. {}",
index.display(),
artifacts::launcher_hint(version)
));
}
if digest(&index, "sha1")? != sha1 {
return Err(format!("{} is not the pinned asset index", index.display()));
}
Ok((assets, index))
}
/// Where the launcher's asset store keeps the object with this SHA-1.
fn object_path(assets: &Path, checksum: &str) -> Result<PathBuf, String> {
if checksum.len() != 40 || !checksum.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(format!("Bad asset hash {checksum:?} in the asset index"));
}
Ok(assets.join("objects").join(&checksum[..2]).join(checksum))
}
/// One verified object from the launcher's asset store (tools/lab.py `launcher_object`).
fn launcher_object(assets: &Path, checksum: &str) -> Result<PathBuf, String> {
let path = object_path(assets, checksum)?;
if !path.is_file() {
return Err(format!(
"Missing asset object {}. Play the pinned version once in the official launcher.",
path.display()
));
}
if digest(&path, "sha1")? != checksum {
return Err(format!(
"Corrupt asset object {}; let the launcher repair it",
path.display()
));
}
Ok(path)
}
/// Whether the game plays this asset: sounds.json and everything under sounds/.
fn is_sound(name: &str) -> bool {
name == "minecraft/sounds.json" || name.starts_with("minecraft/sounds/")
}
/// Copies the pinned version's asset index and sounds from the official launcher's game
/// directory (`minecraft`, else HORIZON_MINECRAFT_DIR, else the launcher's own) into `root`'s
/// `.local/horizon-client/<version>`; returns that folder. Files already there with the right
/// SHA-1 are kept.
pub fn sounds(root: &Path, minecraft: Option<&Path>, log: Log) -> Result<PathBuf, String> {
let lock = Lock::read(root)?;
let minecraft = artifacts::minecraft_dir(minecraft);
let manifest = artifacts::import_manifest(&lock, &minecraft)?;
let (store, index) = launcher_assets(&manifest, &minecraft)?;
let id = manifest
.get("id")
.and_then(Value::as_str)
.ok_or("The version JSON has no id")?;
let target = root.join(".local/horizon-client").join(id);
fs::create_dir_all(&target).map_err(|e| format!("{}: {e}", target.display()))?;
fs::copy(&index, target.join("asset-index.json"))
.map_err(|e| format!("{}: {e}", target.join("asset-index.json").display()))?;
let objects = read_json(&index)?;
let objects = objects
.get("objects")
.and_then(Value::as_object)
.ok_or_else(|| format!("{} has no objects", index.display()))?;
// A repeated name keeps its first place and its last value, as a Python dict does.
let mut wanted: Vec<(&str, &Value)> = Vec::new();
for (name, info) in objects.iter().filter(|(name, _)| is_sound(name)) {
match wanted.iter_mut().find(|(n, _)| n == name) {
Some(entry) => entry.1 = info,
None => wanted.push((name, info)),
}
}
let mut total: u64 = 0;
for (name, info) in &wanted {
let hash = info
.get("hash")
.and_then(Value::as_str)
.ok_or_else(|| format!("{name} has no hash in the asset index"))?;
let size = info
.get("size")
.and_then(Value::as_i64)
.and_then(|s| u64::try_from(s).ok())
.ok_or_else(|| format!("{name} has no size in the asset index"))?;
let relative = artifacts::safe_relative(name)
.ok_or_else(|| format!("Unsafe asset name {name:?} in the asset index"))?;
let source = launcher_object(&store, hash)?;
artifacts::import_copy(
&source,
&target.join("assets").join(relative),
hash,
"sha1",
"",
)?;
total += size;
}
log(&format!(
"Sound assets ready: {} files, {:.1} MB",
wanted.len(),
total as f64 / 1e6
));
Ok(target)
}
#[cfg(test)]
mod tests {
use super::*;
use sha1::Digest;
fn sha1_hex(data: &[u8]) -> String {
crate::hex(&sha1::Sha1::digest(data))
}
/// A Horizon folder and an official launcher directory holding version `9.9` with
/// sounds.json, two sounds and a language file in its asset store.
struct Fixture {
dir: PathBuf,
root: PathBuf,
minecraft: PathBuf,
objects: Vec<(&'static str, Vec<u8>)>,
}
impl Fixture {
fn new(name: &str) -> Fixture {
let dir = std::env::temp_dir().join(format!(
"horizon-prepare-sounds-{name}-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&dir);
let root = dir.join("horizon");
let minecraft = dir.join("minecraft");
let objects: Vec<(&'static str, Vec<u8>)> = vec![
("minecraft/sounds.json", b"{\"a\": {}}".to_vec()),
("minecraft/sounds/ambient/cave1.ogg", b"OggS cave".to_vec()),
("minecraft/sounds/dig/stone1.ogg", b"OggS stone".to_vec()),
("minecraft/lang/fr_fr.json", b"{}".to_vec()),
];
let mut index = String::from("{\"objects\": {");
for (i, (name, data)) in objects.iter().enumerate() {
let hash = sha1_hex(data);
let path = minecraft
.join("assets/objects")
.join(&hash[..2])
.join(&hash);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, data).unwrap();
let comma = if i > 0 { ", " } else { "" };
index += &format!(
"{comma}\"{name}\": {{\"hash\": \"{hash}\", \"size\": {}}}",
data.len()
);
}
index += "}}";
let indexes = minecraft.join("assets/indexes");
fs::create_dir_all(&indexes).unwrap();
fs::write(indexes.join("7.json"), &index).unwrap();
let manifest = format!(
"{{\"id\": \"9.9\", \"assetIndex\": {{\"id\": \"7\", \"sha1\": \"{}\"}}, \
\"downloads\": {{\"client\": {{\"sha1\": \"c\"}}, \"server\": {{\"sha1\": \"s\"}}}}, \
\"javaVersion\": {{\"majorVersion\": 25}}}}",
sha1_hex(index.as_bytes())
);
let versions = minecraft.join("versions/9.9");
fs::create_dir_all(&versions).unwrap();
fs::write(versions.join("9.9.json"), &manifest).unwrap();
fs::create_dir_all(&root).unwrap();
fs::write(
root.join("research-lock.json"),
format!(
"{{\"minecraft\": {{\"version\": \"9.9\", \"java_major\": 25, \
\"client_sha1\": \"c\", \"server_sha1\": \"s\", \"manifest_sha1\": \"{}\"}}}}",
sha1_hex(manifest.as_bytes())
),
)
.unwrap();
Fixture {
dir,
root,
minecraft,
objects,
}
}
fn run(&self) -> Result<PathBuf, String> {
sounds(&self.root, Some(&self.minecraft), &|_| {})
}
fn object(&self, i: usize) -> PathBuf {
let hash = sha1_hex(&self.objects[i].1);
self.minecraft
.join("assets/objects")
.join(&hash[..2])
.join(hash)
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.dir);
}
}
#[test]
fn sounds_land_where_the_game_reads_them() {
let f = Fixture::new("layout");
let lines = std::cell::RefCell::new(Vec::new());
let target = sounds(&f.root, Some(&f.minecraft), &|l| {
lines.borrow_mut().push(l.to_string())
})
.unwrap();
assert_eq!(target, f.root.join(".local/horizon-client/9.9"));
for (name, data) in &f.objects[..3] {
assert_eq!(&fs::read(target.join("assets").join(name)).unwrap(), data);
}
assert!(!target.join("assets/minecraft/lang").exists());
assert_eq!(
fs::read(target.join("asset-index.json")).unwrap(),
fs::read(f.minecraft.join("assets/indexes/7.json")).unwrap()
);
assert!(f.root.join(".local/minecraft/9.9/manifest.json").is_file());
assert_eq!(*lines.borrow(), ["Sound assets ready: 3 files, 0.0 MB"]);
// Again: the verified copies are kept.
f.run().unwrap();
}
#[test]
fn every_object_is_checked() {
let f = Fixture::new("corrupt");
fs::write(f.object(1), b"not the sound").unwrap();
let error = f.run().unwrap_err();
assert!(error.starts_with("Corrupt asset object"), "{error}");
assert!(!f
.root
.join(".local/horizon-client/9.9/assets/minecraft/sounds/ambient/cave1.ogg")
.exists());
fs::remove_file(f.object(1)).unwrap();
assert!(f.run().unwrap_err().starts_with("Missing asset object"));
}
#[test]
fn the_index_must_be_the_pinned_one() {
let f = Fixture::new("index");
let index = f.minecraft.join("assets/indexes/7.json");
fs::write(&index, "{\"objects\": {}}").unwrap();
assert!(f
.run()
.unwrap_err()
.ends_with("is not the pinned asset index"));
fs::remove_file(&index).unwrap();
let error = f.run().unwrap_err();
assert!(
error.starts_with("Missing ") && error.contains("play 9.9 once"),
"{error}"
);
}
#[test]
fn object_paths_come_from_real_hashes() {
let store = Path::new("assets");
let hash = "abfaa525f923f807df8b4e4d29c1b5e3a104adbe";
assert_eq!(
object_path(store, hash).unwrap(),
store.join("objects/ab").join(hash)
);
assert!(object_path(store, "../../etc/passwd").is_err());
assert!(is_sound("minecraft/sounds/x.ogg") && is_sound("minecraft/sounds.json"));
assert!(!is_sound("minecraft/soundsx.json") && !is_sound("realms/sounds/x.ogg"));
}
}
+6 -5
View File
@@ -14,23 +14,24 @@ missing; [Rust rewrite status](rust-parity.md) has the details.
## Run from a checkout
Use current stable Rust (1.90+) and JDK 25 (Python 3.10+ for the sounds and the
development harnesses). Horizon never
Use current stable Rust (1.90+) and JDK 25 (Python 3.10+ only for 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)),
verifies it against the pinned checksums, runs vanilla's data generator and small
exporters against those jars, and extracts the jar's assets and data into ignored
`.local/horizon-client/26.3/`. Sounds (about 380 MB) are copied from your
launcher's asset store. Prepared resources are private; never commit them.
launcher's asset store by `horizon-prepare sounds`, each file checked against the asset
index's SHA-1. 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.
`horizon-client`, and the launcher prepares with it and copies the sounds as part of preparing.
```sh
cargo run --release -p horizon-prepare -- --prepare --server-jar ~/Downloads/server.jar
python3 tools/fetch_sounds.py
cargo run --release -p horizon-prepare -- sounds
cargo run --release -p horizon-client -- --render-distance 12
```
+3 -1
View File
@@ -15,7 +15,9 @@ 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, and Java 25), then starts the client, or a
26.3 server jar from that version's own json, and Java 25) and copies its sounds from your
launcher's asset store (`horizon-prepare sounds`; a launcher install without them only
leaves the game silent), 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
+48 -1
View File
@@ -441,7 +441,7 @@ 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).
/// Where a version folder keeps the preparer's sources (the release package copies them).
const PREPARER_CRATE: &str = "crates/horizon-prepare";
/// The files under `dir/relative`, as paths relative to `dir`.
@@ -679,6 +679,53 @@ pub fn preparer(tag: &str, minecraft: &Path, log: &dyn Fn(String)) -> Result<Pre
Ok(Preparer::Process(cmd))
}
/// Copies the pinned version's sounds from the player's own launcher install into the version's
/// resources (`horizon-prepare sounds`), each line to `log`. Sound is optional, so a launcher
/// install without them is a line in the log, never a failed preparation. The version's own
/// preparer does it when it is not the launcher's and knows how; versions from before it read
/// the sounds where the launcher's preparer puts them.
pub fn sounds(tag: &str, minecraft: &Path, log: &dyn Fn(String)) {
let dir = version_dir(tag);
let own = horizon_prepare::sources_sha256(&dir.join(PREPARER_CRATE)).as_deref()
== Some(horizon_prepare::SOURCES_SHA256);
let binary = preparer_binary(&dir).filter(|binary| {
!own && Command::new(binary)
.arg("--help")
.output()
.is_ok_and(|o| String::from_utf8_lossy(&o.stdout).contains("horizon-prepare sounds"))
});
log("Copying the sounds from your Minecraft install".into());
let result = match binary {
Some(binary) => Command::new(binary)
.current_dir(&dir)
.arg("sounds")
.arg("--root")
.arg(&dir)
.arg("--minecraft-dir")
.arg(minecraft)
.output()
.map_err(|e| e.to_string())
.and_then(|o| {
let text = |b: &[u8]| String::from_utf8_lossy(b).trim().to_string();
for line in text(&o.stdout).lines() {
log(line.to_string());
}
match o.status.success() {
true => Ok(()),
false => Err(text(&o.stderr).trim_start_matches("error: ").to_string()),
}
}),
None => std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
horizon_prepare::sounds(&dir, Some(minecraft), &|line| log(line.to_string()))
}))
.unwrap_or_else(|_| Err("The sound copy stopped unexpectedly".into()))
.map(|_| ()),
};
if let Err(e) = result {
log(format!("No sound (the game will be silent): {e}"));
}
}
/// 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(
+3 -1
View File
@@ -215,7 +215,9 @@ 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)?;
match backend::preparer(&tag, &minecraft, &|line| eprintln!("{line}"))? {
let preparer = backend::preparer(&tag, &minecraft, &|line| eprintln!("{line}"))?;
backend::sounds(&tag, &minecraft, &|line| println!("{line}"));
match preparer {
backend::Preparer::Process(cmd) => attached(cmd)?,
backend::Preparer::InProcess(options) => {
backend::prepare_in_process(&options, &|line| println!("{line}"))?
+10 -8
View File
@@ -515,14 +515,16 @@ impl Launcher {
let log = |line| {
let _ = tx.send(Msg::Line(Job::Prepare, line));
};
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)
}
});
let result = backend::preparer(&tag, &minecraft, &log)
.inspect(|_| backend::sounds(&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 {
// A process sends its own Exit when it ends.
Ok(false) => {}
+5 -1
View File
@@ -97,7 +97,10 @@ 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 (result, tail) = match backend::preparer(&tag, &minecraft, &|_| {})? {
let preparer = backend::preparer(&tag, &minecraft, &|_| {})?;
let sound = std::cell::RefCell::new(Vec::new());
backend::sounds(&tag, &minecraft, &|line| sound.borrow_mut().push(line));
let (result, tail) = match preparer {
backend::Preparer::Process(cmd) => {
let run = Logged::start(Job::Prepare, cmd)?;
let result = run.wait();
@@ -120,6 +123,7 @@ impl Session {
(result, tail)
}
};
let tail = format!("{}\n{tail}", sound.into_inner().join("\n"));
result
.map(|_| format!("{tag} is prepared.\n{tail}"))
.map_err(|e| format!("{e}\n{tail}"))
+4 -4
View File
@@ -19,10 +19,10 @@ Downloaded this archive directly?
To prepare this folder by hand you need Minecraft Java Edition 26.3, played
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-prepare sounds (optional, for sound)
(Windows: horizon-prepare.exe.) The 26.3 server jar is the downloads.server.url
in your launcher's versions/26.3/26.3.json. Add --minecraft-dir PATH to both if
your launcher keeps the game elsewhere. Then run
./horizon-client (horizon-client.exe on Windows; on macOS the first time:
right-click > Open). horizon-client --help lists the options.
+1 -1
View File
@@ -33,7 +33,7 @@ COMMON_SOURCES = [
def require_sounds():
if not (RESOURCES / 'assets/minecraft/sounds.json').is_file():
raise SystemExit('Sound assets missing: run tools/fetch_sounds.py first')
raise SystemExit('Sound assets missing: run cargo run -p horizon-prepare -- sounds first')
def run_harness(name, oracle, example, inputs_text, extra_sources=(), java_args=(), rust_args=(), scope='', timeout=1800):
-36
View File
@@ -1,36 +0,0 @@
#!/usr/bin/env python3
"""Copy the pinned version's sound assets (sounds.json and .ogg files) from your
own official launcher install into the private client resource directory,
verifying every object's SHA-1. Nothing is downloaded; the files stay in
ignored .local/ and are never committed."""
import argparse
from pathlib import Path
import shutil
import lab
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--minecraft-dir', type=Path,
help="official launcher game directory (default: the launcher's own)")
args = parser.parse_args()
lock, directory = lab.config()
manifest = lab.import_manifest(lock, directory, args.minecraft_dir)
store, index = lab.launcher_assets(manifest, args.minecraft_dir)
target = lab.LOCAL / 'horizon-client' / manifest['id']
target.mkdir(parents=True, exist_ok=True)
shutil.copyfile(index, target / 'asset-index.json')
objects = lab.json.loads(index.read_text())['objects']
wanted = {name: info for name, info in objects.items()
if name == 'minecraft/sounds.json' or name.startswith('minecraft/sounds/')}
total = 0
for name, info in wanted.items():
source = lab.launcher_object(store, info['hash'])
lab.import_copy(source, target / 'assets' / name, info['hash'])
total += info['size']
print(f'Sound assets ready: {len(wanted)} files, {total / 1e6:.1f} MB')
if __name__ == '__main__':
main()
+1 -1
View File
@@ -87,7 +87,7 @@ def main():
if not (args.resources / 'horizon-resources.json').is_file():
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')
print('warning: no sound assets; run cargo run -p horizon-prepare -- sounds for audio')
system = {'Linux': 'linux', 'Darwin': 'macos'}.get(platform.system())
if system is None: