mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
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:
1 parent
b2462d54c4
commit
8355a6ff14
15 files changed
+408
-65
No files matched your search
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user