horizon-prepare: name the game files one by one (#3545)

--version-json, --client-jar, --libraries-dir and --assets-dir name
the files a launcher keeps in a layout of its own (instance folders,
shared libraries and assets), each replacing only what --minecraft-dir
would give; every file is still checked against research-lock.json's
pins. GameFiles gains the libraries and asset store folders, and
Options the Inputs (Default in the launcher, so it is unchanged).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
GIGABAIT93andClaude Opus 5.5 committed 2026-10-29 10:47:51 +02:00
1 parent 82cf8916e2
commit 7f8c190b29
6 files changed
+188 -22

No files matched your search

+129 -8
View File
@@ -15,7 +15,8 @@ const LAUNCHER_HINT: &str =
Minecraft Java Edition with so it downloads the game's files, then rerun. Horizon reads the \
official launcher's folder, ATLauncher's and the CurseForge app's; for Prism Launcher, MultiMC, \
the Modrinth App or GDLauncher, prepare from the Horizon launcher, which finds their files. If your launcher keeps \
them in another folder, pass --minecraft-dir or set HORIZON_MINECRAFT_DIR.";
them in another folder, pass --minecraft-dir or set HORIZON_MINECRAFT_DIR, or name the files with \
--version-json, --client-jar, --libraries-dir and --assets-dir.";
const SERVER_HINT: &str = "Horizon does not download the game: download the {version} server jar yourself (minecraft.net/download/server \
while {version} is the current release, otherwise the downloads.server.url in your launcher's \
versions/{version}/{version}.json), then pass --server-jar PATH or set HORIZON_SERVER_JAR.";
@@ -137,13 +138,40 @@ pub struct GameFiles {
pub folder: PathBuf,
pub manifest: PathBuf,
pub client: PathBuf,
/// Where the client's libraries are (`folder`'s `libraries`, or `--libraries-dir`).
pub libraries: PathBuf,
/// The asset store: `indexes/` and `objects/` (`folder`'s `assets`, or `--assets-dir`).
pub assets: PathBuf,
/// Mojang's Java runtimes the launcher downloaded (java::minecraft_runtimes).
pub runtimes: PathBuf,
/// The folders looked in, each with its launcher, when none held the version.
searched: Vec<(&'static str, PathBuf)>,
}
/// Files named one by one (`--version-json`, `--client-jar`, `--libraries-dir`, `--assets-dir`),
/// for launchers whose layout is not one `game_files` knows: each replaces what it names.
#[derive(Clone, Debug, Default)]
pub struct Inputs {
pub version_json: Option<PathBuf>,
pub client_jar: Option<PathBuf>,
pub libraries_dir: Option<PathBuf>,
pub assets_dir: Option<PathBuf>,
}
impl GameFiles {
/// These files with what `inputs` names instead.
pub fn with(mut self, inputs: &Inputs) -> Self {
let pick = |given: &Option<PathBuf>, found: &mut PathBuf| {
if let Some(given) = given.as_ref().filter(|p| !p.as_os_str().is_empty()) {
*found = expand_user(given);
}
};
pick(&inputs.version_json, &mut self.manifest);
pick(&inputs.client_jar, &mut self.client);
pick(&inputs.libraries_dir, &mut self.libraries);
pick(&inputs.assets_dir, &mut self.assets);
self
}
/// What to do when these files are missing, with the folders looked in.
pub(crate) fn hint(&self, version: &str) -> String {
let mut text = hint(LAUNCHER_HINT, version);
@@ -165,6 +193,8 @@ fn layouts(folder: &Path, version: &str) -> [GameFiles; 2] {
folder: folder.to_path_buf(),
manifest: folder.join(manifest),
client: folder.join(client),
libraries: folder.join("libraries"),
assets: folder.join("assets"),
runtimes: folder.join(runtimes),
searched: Vec::new(),
};
@@ -527,7 +557,11 @@ pub(crate) fn safe_relative(path: &str) -> Option<PathBuf> {
/// `com/mojang/jtracy-natives-windows/1.14.38/jtracy-natives-windows-1.14.38.jar`. The path the
/// version JSON gives is returned when neither exists, for the error to name.
pub fn launcher_library(minecraft: &Path, name: &str, relative: &Path) -> PathBuf {
let libraries = minecraft.join("libraries");
library_in(&minecraft.join("libraries"), name, relative)
}
/// `launcher_library` in a libraries folder of its own.
fn library_in(libraries: &Path, name: &str, relative: &Path) -> PathBuf {
let official = libraries.join(relative);
if official.is_file() {
return official;
@@ -640,10 +674,11 @@ pub fn import(
root: &Path,
lock: &Lock,
minecraft: Option<&Path>,
inputs: &Inputs,
server_jar: Option<&Path>,
log: Log,
) -> Result<(), String> {
let files = game_files(minecraft, &lock.version);
let files = game_files(minecraft, &lock.version).with(inputs);
let minecraft = &files.folder;
let manifest = import_manifest(lock, &files)?;
let client = import_copy(
@@ -676,12 +711,12 @@ pub fn import(
for (name, artifact) in client_library_artifacts(&manifest)? {
let (path, sha1) = artifact_fields(artifact)?;
let relative = safe_relative(path).ok_or("Unsafe library path")?;
let source = launcher_library(minecraft, name, &relative);
let source = library_in(&files.libraries, name, &relative);
let destination = lock.directory.join("libraries/client").join(&relative);
if runs_without(name) && !source.is_file() && !destination.exists() {
log(&format!(
"No {name} in {}: the game runs without it",
minecraft.display()
files.libraries.display()
));
continue;
}
@@ -1339,7 +1374,16 @@ mod tests {
.unwrap();
let lock = Lock::read(&fixture.target).unwrap();
let server = old.join("jars/server.jar");
let prepare = || import(&fixture.target, &lock, Some(&prism), Some(&server), &|_| {});
let prepare = || {
import(
&fixture.target,
&lock,
Some(&prism),
&Inputs::default(),
Some(&server),
&|_| {},
)
};
let imported = || {
let manifest = verify(&lock).unwrap().manifest;
client_libraries(&lock, &manifest).unwrap().len()
@@ -1405,7 +1449,14 @@ mod tests {
let server = old.join("jars/server.jar");
let prepare = |dir: &Path| {
let _ = fs::remove_dir_all(&lock.directory);
import(&fixture.target, &lock, Some(dir), Some(&server), &|_| {})
import(
&fixture.target,
&lock,
Some(dir),
&Inputs::default(),
Some(&server),
&|_| {},
)
};
for chosen in [atlauncher.clone(), atlauncher.join("instances/Minecraft99")] {
@@ -1438,6 +1489,68 @@ mod tests {
.contains("may have rewritten it"));
}
/// A launcher with a layout of its own names each file (`--version-json`, `--client-jar`,
/// `--libraries-dir`): they prepare with the same pins, from no Minecraft folder at all.
#[test]
fn named_inputs_prepare_without_a_minecraft_folder() {
let fixture = Imports::new();
let old = fixture.imported(&fixture.source);
let own = fixture.directory.join("OwnLauncher");
let put = |path: &str, data: &[u8]| {
let path = own.join(path);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, data).unwrap();
};
let client = fs::read(old.join("jars/client.jar")).unwrap();
put("instance/minecraft.jar", &client);
put("shared/libs/client.jar", b"client library");
let manifest = fs::read_to_string(old.join("manifest.json")).unwrap();
let manifest = manifest.replacen('{', r#"{"javaVersion":{"majorVersion":25},"#, 1);
put("meta/9.9.json", manifest.as_bytes());
let pinned = digest(&own.join("meta/9.9.json"), "sha1").unwrap();
let lock_path = fixture.target.join("research-lock.json");
let text = fs::read_to_string(&lock_path).unwrap();
let old_sha1 = digest(&old.join("manifest.json"), "sha1").unwrap();
fs::write(&lock_path, text.replace(&old_sha1, &pinned)).unwrap();
let inputs = Inputs {
version_json: Some(own.join("meta/9.9.json")),
client_jar: Some(own.join("instance/minecraft.jar")),
libraries_dir: Some(own.join("shared/libs")),
assets_dir: None,
};
let lock = Lock::read(&fixture.target).unwrap();
let server = old.join("jars/server.jar");
let empty = fixture.directory.join("no-minecraft");
fs::create_dir_all(&empty).unwrap();
let prepare = |inputs: &Inputs| {
let _ = fs::remove_dir_all(&lock.directory);
import(
&fixture.target,
&lock,
Some(&empty),
inputs,
Some(&server),
&|_| {},
)
};
prepare(&inputs).unwrap();
let artifacts = verify(&lock).unwrap();
assert_eq!(fs::read(&artifacts.jars["client"]).unwrap(), client);
assert_eq!(
client_libraries(&lock, &artifacts.manifest).unwrap().len(),
1
);
// Without them the empty folder has nothing; a wrong jar fails its pin.
assert!(prepare(&Inputs::default())
.unwrap_err()
.starts_with("Missing "));
put("instance/minecraft.jar", b"other");
assert!(prepare(&inputs)
.unwrap_err()
.contains("is not the pinned file"));
}
/// With no launcher's files in the chosen folder, the error names where Horizon looked and
/// the launchers it reads.
#[test]
@@ -1446,7 +1559,15 @@ mod tests {
let empty = fixture.directory.join("instances/empty");
fs::create_dir_all(&empty).unwrap();
let lock = Lock::read(&fixture.target).unwrap();
let error = import(&fixture.target, &lock, Some(&empty), None, &|_| {}).unwrap_err();
let error = import(
&fixture.target,
&lock,
Some(&empty),
&Inputs::default(),
None,
&|_| {},
)
.unwrap_err();
assert!(error.starts_with(&format!(
"Missing {}",
empty.join("versions/9.9/9.9.json").display()
+7 -1
View File
@@ -98,6 +98,9 @@ pub struct Options {
/// The Java runtime to try after JAVA_HOME and the PATH (the launcher's pick, e.g. Minecraft's
/// own Java it downloaded from Mojang).
pub java_home: Option<PathBuf>,
/// Files named one by one instead of found in `minecraft_dir` (`--version-json`,
/// `--client-jar`, `--libraries-dir`, `--assets-dir`).
pub inputs: artifacts::Inputs,
/// Where the resources go (`--output`); None for `root`'s `.local/horizon-client/<version>`.
/// The exporters' scratch files stay in `root`'s `.local/` either way.
pub output: Option<PathBuf>,
@@ -331,6 +334,7 @@ struct Run<'a> {
origin: Value,
log: Log<'a>,
progress: Progress<'a>,
inputs: artifacts::Inputs,
/// The stages of `steps` begun so far.
stages: std::cell::Cell<u32>,
}
@@ -493,6 +497,7 @@ fn prepare_impl(
root,
&lock,
options.minecraft_dir.as_deref(),
&options.inputs,
options.server_jar.as_deref(),
log,
)?;
@@ -548,6 +553,7 @@ fn prepare_impl(
origin,
log,
progress,
inputs: options.inputs.clone(),
stages: std::cell::Cell::new(0),
};
if compile_only {
@@ -782,7 +788,7 @@ fn steps(
// the vanilla pack's icon (`pack.png`) for the pack screen.
run.stage();
(run.log)("Copying the client's assets and data");
let mut indexed = sounds::Assets::load(repo, Some(root), minecraft, run.log);
let mut indexed = sounds::Assets::load(repo, Some(root), minecraft, &run.inputs, run.log);
let (version, count) = extract_client_resources(&run.client, root, indexed.as_mut().ok())?;
sounds::optional_imports(indexed.as_mut().map_err(|e| e.as_str()), run.log);
+16
View File
@@ -30,6 +30,11 @@ Usage: horizon-prepare [--prepare] [--server-jar PATH] [--minecraft-dir PATH] [-
(default: HORIZON_SERVER_JAR, or the copy imported before)
--minecraft-dir PATH The official launcher's game directory with your installed copy
(default: HORIZON_MINECRAFT_DIR, or the launcher's own)
--version-json PATH The pinned version's JSON, where --minecraft-dir has none
--client-jar PATH The pinned client jar, where --minecraft-dir has none
--libraries-dir PATH The folder of the client's libraries (Maven layout)
--assets-dir PATH The asset store, with indexes/ and objects/
(each of these four replaces what --minecraft-dir would give)
--root PATH The Horizon folder to prepare (default: the folder this program is
in, else the nearest folder above the current one with research-lock.json)
--output PATH Where the resources go (default: <root>/.local/horizon-client/<version>);
@@ -81,6 +86,7 @@ fn main() -> ExitCode {
minecraft_dir: None,
server_jar: None,
java_home: None,
inputs: Default::default(),
output: None,
};
let mut root = None;
@@ -121,6 +127,14 @@ fn main() -> ExitCode {
}
"--server-jar" => value("--server-jar").map(|v| options.server_jar = Some(v)),
"--minecraft-dir" => value("--minecraft-dir").map(|v| options.minecraft_dir = Some(v)),
"--version-json" => {
value("--version-json").map(|v| options.inputs.version_json = Some(v))
}
"--client-jar" => value("--client-jar").map(|v| options.inputs.client_jar = Some(v)),
"--libraries-dir" => {
value("--libraries-dir").map(|v| options.inputs.libraries_dir = Some(v))
}
"--assets-dir" => value("--assets-dir").map(|v| options.inputs.assets_dir = Some(v)),
"--root" => value("--root").map(|v| root = Some(v)),
"--output" => value("--output").map(|v| options.output = Some(v)),
"--check" if inline.is_none() => {
@@ -193,6 +207,7 @@ fn main() -> ExitCode {
&options.root,
options.output.as_deref(),
options.minecraft_dir.as_deref(),
&options.inputs,
&log,
)
}
@@ -232,6 +247,7 @@ fn complete_resources(
&options.root,
Some(&resources),
options.minecraft_dir.as_deref(),
&options.inputs,
log,
)?;
let lacking = horizon_prepare::check(&options.root, &resources)?;
+30 -13
View File
@@ -3,7 +3,7 @@
//! `.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::artifacts::{self, Inputs, Lock};
use crate::pyjson::Value;
use crate::{digest, Log};
use std::collections::{HashMap, HashSet};
@@ -22,7 +22,7 @@ fn launcher_assets(
cached_index: &Path,
log: Log,
) -> Result<(PathBuf, PathBuf, Vec<u8>), String> {
let assets = files.folder.join("assets");
let assets = files.assets.clone();
let info = manifest
.get("assetIndex")
.ok_or("The version JSON has no assetIndex")?;
@@ -77,20 +77,20 @@ fn is_sound(name: &str) -> bool {
/// `.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> {
add_assets("sounds", root, None, minecraft, log)
add_assets("sounds", root, None, minecraft, &Inputs::default(), log)
}
/// Replaces the client JAR's 1x1 title placeholders with the full panorama from the
/// launcher's asset store. Also usable on already-prepared resources without Java.
pub fn panorama(root: &Path, minecraft: Option<&Path>, log: Log) -> Result<PathBuf, String> {
add_assets("panorama", root, None, minecraft, log)
add_assets("panorama", root, None, minecraft, &Inputs::default(), log)
}
/// Replaces the client JAR's empty `font/include/unifont.json` with the asset index's, and
/// adds the GNU Unifont glyphs it names (`font/unifont*.zip`): the default font's fallback for
/// every character the bitmap fonts lack (#1076). Also usable on prepared resources without Java.
pub fn fonts(root: &Path, minecraft: Option<&Path>, log: Log) -> Result<PathBuf, String> {
add_assets("fonts", root, None, minecraft, log)
add_assets("fonts", root, None, minecraft, &Inputs::default(), log)
}
/// Every bundled language and the language list, both of which only the asset index holds
@@ -98,7 +98,7 @@ pub fn fonts(root: &Path, minecraft: Option<&Path>, log: Log) -> Result<PathBuf,
/// language screen offers, `minecraft/lang/<code>.json` the strings each one draws (#1272).
/// Also usable on already-prepared resources without Java.
pub fn languages(root: &Path, minecraft: Option<&Path>, log: Log) -> Result<PathBuf, String> {
add_assets("languages", root, None, minecraft, log)
add_assets("languages", root, None, minecraft, &Inputs::default(), log)
}
const LANGUAGES: [&str; 1] = ["pack.mcmeta"];
@@ -111,7 +111,7 @@ fn is_language(name: &str) -> bool {
#[cfg(test)]
fn optional_assets(root: &Path, minecraft: Option<&Path>, log: Log) {
optional_imports(
Assets::load(root, None, minecraft, log)
Assets::load(root, None, minecraft, &Inputs::default(), log)
.as_mut()
.map_err(|e| e.as_str()),
log,
@@ -185,7 +185,7 @@ const UNIFONT: [&str; 2] = [
/// Packs made to sit on Programmer Art, like Golden Days, show the default textures without it.
/// Also usable on prepared resources without Java.
pub fn packs(root: &Path, minecraft: Option<&Path>, log: Log) -> Result<PathBuf, String> {
add_assets("packs", root, None, minecraft, log)
add_assets("packs", root, None, minecraft, &Inputs::default(), log)
}
/// The built-in packs `packs` copies, under the prepared resources' `assets`.
@@ -220,19 +220,21 @@ const KINDS: [Kind; 5] = [
];
/// `horizon-prepare <kind>`: copies what `kind` names (`sounds`, `panorama`, `fonts`, `packs` or
/// `languages`) into the resources at `output` (default: `root`'s `.local/horizon-client/<version>`).
/// `languages`) into the resources at `output` (default: `root`'s `.local/horizon-client/<version>`),
/// from the asset store of `minecraft` or the one `inputs` names.
pub fn add_assets(
kind: &str,
root: &Path,
output: Option<&Path>,
minecraft: Option<&Path>,
inputs: &Inputs,
log: Log,
) -> Result<PathBuf, String> {
let (_, wanted, label, required) = KINDS
.iter()
.find(|(name, ..)| *name == kind)
.ok_or_else(|| format!("no assets called {kind}"))?;
Assets::load(root, output, minecraft, log)?.copy(*wanted, label, required, log)
Assets::load(root, output, minecraft, inputs, log)?.copy(*wanted, label, required, log)
}
/// A preparation's index and verified destinations. Never persisted: a later invocation
@@ -249,10 +251,11 @@ impl Assets {
root: &Path,
output: Option<&Path>,
minecraft: Option<&Path>,
inputs: &Inputs,
log: Log,
) -> Result<Self, String> {
let lock = Lock::read(root)?;
let files = artifacts::game_files(minecraft, &lock.version);
let files = artifacts::game_files(minecraft, &lock.version).with(inputs);
let manifest = artifacts::import_manifest(&lock, &files)?;
let target = crate::resources_dir(root, &lock.version, output);
let (store, index, bytes) = launcher_assets(
@@ -840,7 +843,14 @@ mod tests {
#[test]
fn one_index_snapshot_serves_every_import_and_reruns_repair_corruption() {
let f = Fixture::with_extras("snapshot", &PANORAMA, &[("pack.mcmeta", b"{}")]);
let mut assets = Assets::load(&f.root, None, Some(&f.minecraft), &|_| {}).unwrap();
let mut assets = Assets::load(
&f.root,
None,
Some(&f.minecraft),
&Inputs::default(),
&|_| {},
)
.unwrap();
fs::remove_file(f.minecraft.join("assets/indexes/7.json")).unwrap();
let lines = std::cell::RefCell::new(Vec::new());
optional_imports(Ok(&mut assets), &|line| {
@@ -904,7 +914,14 @@ mod tests {
// Preparing from cached JARs must also preserve full imports without the launcher.
fs::remove_dir_all(f.minecraft.join("assets")).unwrap();
for _ in 0..2 {
let mut assets = Assets::load(&f.root, None, Some(&f.minecraft), &|_| {}).unwrap();
let mut assets = Assets::load(
&f.root,
None,
Some(&f.minecraft),
&Inputs::default(),
&|_| {},
)
.unwrap();
let (_, count) =
crate::extract_client_resources(&jar, &target, Some(&mut assets)).unwrap();
assert_eq!(count, 5);
+5
View File
@@ -45,6 +45,11 @@ version until they are prepared again. `--complete` prepares, copies the sounds,
unless `--check` would then pass: one command for resources with nothing missing.
`--progress json` prints one JSON object a line instead of plain text: `{"message": …}` for
each log line, `{"fraction": F}` (0 to 1) as each stage begins, `{"error": …}` on failure.
A launcher whose files are laid out its own way names them one by one instead of
`--minecraft-dir`: `--version-json` (the pinned version's JSON), `--client-jar`,
`--libraries-dir` (the client's libraries, Maven layout) and `--assets-dir` (the asset store,
with `indexes/` and `objects/`); each replaces only what it names, and every file is still
checked against `research-lock.json`'s pins.
Exit codes: 0 done, 1 failed or gaps found, 2 a refused command line.
```sh
+1
View File
@@ -1587,6 +1587,7 @@ pub fn preparer(
minecraft_dir: Some(minecraft.clone()),
server_jar: (!reused).then_some(jar),
java_home: Some(java_home.clone()),
inputs: Default::default(),
output: None,
};
return Ok((Preparer::InProcess(options), minecraft));