Compare commits

..
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 c5221cc92d Launcher: java_launch's Windows paths, one part at a time
The log config and old versions' asset folder were joined from
"a/b" strings; on Windows their tests compared those mixed
separators with paths joined part by part. Join them part by part.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 01:23:12 +01:00
Ludovic Marie a7841ccb18 Merge remote-tracking branch 'origin/main' into feat/launcher-java-command 2026-10-28 23:18:24 +01:00
Ludovic MarieandClaude Opus 5.5 5091896e2a Launcher: the real game's command from its version JSON
java_launch builds the command that starts Minecraft: Java Edition as
the official launcher reads a version file: mainClass; the classpath of
this system's libraries (rules with os name and arch, duplicates once)
and the client jar; arguments.jvm / arguments.game with their rules
(every optional feature off) and ${...} placeholders, or the old
minecraftArguments line with -Djava.library.path and -cp; an old
version's natives unpacked without extract.exclude; virtual and
map_to_resources asset indexes laid out by name; Mojang's logging setup
(-Dlog4j.configurationFile) plus -Dlog4j2.formatMsgNoLookups=true;
-Xmx from the instance and the player's JVM arguments.

Nothing reaches the network; the next PR wires it to Play.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 22:50:02 +01:00
4 changed files with 536 additions and 1 deletions

No files matched your search

+322
View File
@@ -0,0 +1,322 @@
//! The command that starts the real Minecraft: Java Edition, read from its version JSON as the
//! official launcher reads it: the main class, the classpath of this system's libraries and the
//! client jar, the JVM and game arguments with their rules and `${…}` placeholders (or the old
//! `minecraftArguments` line), an old version's natives unpacked into a folder of their own, and
//! the logging setup Mojang gives (for 1.7 to 1.18, the one that keeps Log4Shell out).
//!
//! The files are where `mojang::download` puts them: `versions/`, `libraries/` and `assets/` in
//! one game folder. Nothing here reaches the network.
use crate::mojang;
use serde_json::Value;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
/// Who plays: the account's name, UUID, Minecraft access token and Xbox user id.
pub struct Player {
pub name: String,
pub uuid: String,
pub token: String,
pub xuid: String,
}
/// Everything the command needs besides the version JSON.
pub struct Launch<'a> {
/// Holds `versions/`, `libraries/`, `assets/`.
pub minecraft: &'a Path,
/// The instance's game folder (saves, options.txt, mods).
pub game_dir: &'a Path,
/// Where an old version's natives are unpacked.
pub natives: &'a Path,
/// The `java` program.
pub java: &'a Path,
pub memory_mb: u32,
/// The player's own JVM arguments.
pub jvm_args: &'a [String],
pub player: &'a Player,
/// `osx`, `linux` or `windows`, as version files name systems.
pub os: &'a str,
}
/// This system's name in version files.
pub fn os_name() -> &'static str {
match crate::sources::Os::current() {
crate::sources::Os::Linux => "linux",
crate::sources::Os::MacOs => "osx",
crate::sources::Os::Windows => "windows",
}
}
/// Whether a rule list lets this system in: the last matching rule decides; no rules allow.
/// Every optional feature (demo, custom resolution, quick play) is off.
fn allowed(rules: &Value, os: &str) -> bool {
let Some(rules) = rules.as_array() else {
return true;
};
let arch_matches = |arch: &str| match arch {
"x86" => cfg!(target_arch = "x86"),
"arm64" | "aarch64" => cfg!(target_arch = "aarch64"),
"x86_64" | "amd64" => cfg!(target_arch = "x86_64"),
_ => false,
};
let mut allow = false;
for rule in rules {
let system = &rule["os"];
let matches = system["name"].as_str().is_none_or(|n| n == os)
&& system["arch"].as_str().is_none_or(arch_matches)
&& rule["features"]
.as_object()
.is_none_or(|f| f.values().all(|v| v == false));
if matches {
allow = rule["action"] == "allow";
}
}
allow
}
/// The libraries this system uses, then the client jar.
pub fn classpath(version: &Value, minecraft: &Path, os: &str) -> Result<Vec<PathBuf>, String> {
let id = version["id"].as_str().ok_or("The version file has no id")?;
let mut paths: Vec<PathBuf> = Vec::new();
for library in version["libraries"].as_array().into_iter().flatten() {
if !allowed(&library["rules"], os) {
continue;
}
if let Some(path) = library["downloads"]["artifact"]["path"].as_str() {
let path = minecraft.join("libraries").join(mojang::relative(path)?);
if !paths.contains(&path) {
paths.push(path);
}
}
}
paths.push(
minecraft
.join("versions")
.join(mojang::relative(id)?)
.join(format!("{id}.jar")),
);
Ok(paths)
}
/// Unpacks this system's natives of an old version (a library's classifier) into `to`, without
/// what the library's `extract.exclude` names (META-INF/). Versions since 1.19 have none.
pub fn unpack_natives(
version: &Value,
minecraft: &Path,
os: &str,
to: &Path,
) -> Result<(), String> {
fs::create_dir_all(to).map_err(|e| format!("{}: {e}", to.display()))?;
for library in version["libraries"].as_array().into_iter().flatten() {
if !allowed(&library["rules"], os) {
continue;
}
let Some(key) = mojang::native_classifier(library, os) else {
continue;
};
let Some(path) = library["downloads"]["classifiers"][key.as_str()]["path"].as_str() else {
continue;
};
let jar = minecraft.join("libraries").join(mojang::relative(path)?);
let exclude: Vec<&str> = library["extract"]["exclude"]
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_str)
.collect();
let file = fs::File::open(&jar).map_err(|e| format!("{}: {e}", jar.display()))?;
let mut zip = zip::ZipArchive::new(file).map_err(|e| format!("{}: {e}", jar.display()))?;
for i in 0..zip.len() {
let mut entry = zip.by_index(i).map_err(|e| e.to_string())?;
let Some(name) = entry.enclosed_name() else {
continue;
};
let text = name.to_string_lossy().replace('\\', "/");
if entry.is_dir() || exclude.iter().any(|x| text.starts_with(x)) {
continue;
}
let out = to.join(&name);
fs::create_dir_all(out.parent().unwrap()).map_err(|e| e.to_string())?;
let mut file = fs::File::create(&out).map_err(|e| format!("{}: {e}", out.display()))?;
std::io::copy(&mut entry, &mut file).map_err(|e| e.to_string())?;
}
}
Ok(())
}
/// Where an old version reads its assets by name (`virtual` indexes, `map_to_resources`), with
/// the objects copied there once; the asset store itself for every other version.
pub fn game_assets(version: &Value, minecraft: &Path, game_dir: &Path) -> Result<PathBuf, String> {
let id = version["assetIndex"]["id"].as_str().unwrap_or("legacy");
let assets = minecraft.join("assets");
let index = assets
.join("indexes")
.join(mojang::relative(&format!("{id}.json"))?);
let Ok(text) = fs::read_to_string(&index) else {
return Ok(assets);
};
let index: Value =
serde_json::from_str(&text).map_err(|e| format!("{}: {e}", index.display()))?;
let to = if index["map_to_resources"] == true {
game_dir.join("resources")
} else if index["virtual"] == true {
assets.join("virtual").join(mojang::relative(id)?)
} else {
return Ok(assets);
};
for (name, object) in index["objects"].as_object().into_iter().flatten() {
let Some(hash) = object["hash"].as_str().filter(|h| h.len() == 40) else {
continue;
};
let out = to.join(mojang::relative(name)?);
if !out.is_file() {
let from = assets.join("objects").join(&hash[..2]).join(hash);
fs::create_dir_all(out.parent().unwrap())
.and_then(|_| fs::copy(&from, &out))
.map_err(|e| format!("{}: {e}", from.display()))?;
}
}
Ok(to)
}
/// The arguments of one list (`arguments.jvm` or `arguments.game`), their rules applied.
fn list(args: &Value, os: &str) -> Vec<String> {
let mut out = Vec::new();
for arg in args.as_array().into_iter().flatten() {
match arg {
Value::String(s) => out.push(s.clone()),
Value::Object(o) if allowed(&o["rules"], os) => match &o["value"] {
Value::String(s) => out.push(s.clone()),
Value::Array(values) => {
out.extend(values.iter().filter_map(Value::as_str).map(String::from))
}
_ => {}
},
_ => {}
}
}
out
}
/// `${name}` replaced by its value; an unknown one stays as written.
fn fill(arg: &str, vars: &HashMap<&str, String>) -> String {
let mut out = String::new();
let mut rest = arg;
while let Some(start) = rest.find("${") {
let Some(len) = rest[start..].find('}') else {
break;
};
out.push_str(&rest[..start]);
let key = &rest[start + 2..start + len];
match vars.get(key) {
Some(value) => out.push_str(value),
None => out.push_str(&rest[start..start + len + 1]),
}
rest = &rest[start + len + 1..];
}
out.push_str(rest);
out
}
/// The command that starts `version` (its JSON) as `launch` says, run in the game folder.
/// Unpacks natives and lays out old assets first.
pub fn command(version: &Value, launch: &Launch) -> Result<Command, String> {
let id = version["id"].as_str().ok_or("The version file has no id")?;
let main = version["mainClass"]
.as_str()
.ok_or("The version file has no mainClass")?;
let separator = if cfg!(windows) { ";" } else { ":" };
let classpath = classpath(version, launch.minecraft, launch.os)?;
unpack_natives(version, launch.minecraft, launch.os, launch.natives)?;
let assets = launch.minecraft.join("assets");
let game_assets = game_assets(version, launch.minecraft, launch.game_dir)?;
let path = |p: &Path| p.to_string_lossy().into_owned();
let p = launch.player;
let vars: HashMap<&str, String> = HashMap::from([
("auth_player_name", p.name.clone()),
("auth_uuid", p.uuid.clone()),
("auth_access_token", p.token.clone()),
("auth_session", format!("token:{}:{}", p.token, p.uuid)),
("auth_xuid", p.xuid.clone()),
("clientid", String::new()),
("user_type", "msa".into()),
("user_properties", "{}".into()),
("version_name", id.to_string()),
(
"version_type",
version["type"].as_str().unwrap_or("release").into(),
),
("game_directory", path(launch.game_dir)),
("assets_root", path(&assets)),
("game_assets", path(&game_assets)),
(
"assets_index_name",
version["assetIndex"]["id"]
.as_str()
.unwrap_or("legacy")
.into(),
),
("natives_directory", path(launch.natives)),
(
"library_directory",
path(&launch.minecraft.join("libraries")),
),
("classpath_separator", separator.into()),
(
"classpath",
classpath
.iter()
.map(|c| path(c))
.collect::<Vec<_>>()
.join(separator),
),
("launcher_name", "horizon-launcher".into()),
("launcher_version", env!("CARGO_PKG_VERSION").into()),
]);
let mut jvm = vec![
format!("-Xmx{}M", launch.memory_mb),
// Whatever the logging setup, never look names up in log messages.
"-Dlog4j2.formatMsgNoLookups=true".to_string(),
];
let logging = &version["logging"]["client"];
if let (Some(arg), Some(file)) = (logging["argument"].as_str(), logging["file"]["id"].as_str())
{
let config = launch
.minecraft
.join("assets")
.join("log_configs")
.join(mojang::relative(file)?);
jvm.push(arg.replace("${path}", &path(&config)));
}
let (jvm_list, game) = match version["arguments"].as_object() {
Some(a) => (list(&a["jvm"], launch.os), list(&a["game"], launch.os)),
// Before 1.13: one line of game arguments, and the JVM's natives and classpath.
None => (
vec![
"-Djava.library.path=${natives_directory}".into(),
"-cp".into(),
"${classpath}".into(),
],
version["minecraftArguments"]
.as_str()
.unwrap_or("")
.split_whitespace()
.map(String::from)
.collect(),
),
};
jvm.extend(jvm_list.iter().map(|a| fill(a, &vars)));
jvm.extend(launch.jvm_args.iter().cloned());
let mut cmd = Command::new(launch.java);
cmd.current_dir(launch.game_dir)
.args(&jvm)
.arg(main)
.args(game.iter().map(|a| fill(a, &vars)));
Ok(cmd)
}
#[cfg(test)]
#[path = "java_launch/tests.rs"]
mod tests;
+212
View File
@@ -0,0 +1,212 @@
//! Version files shaped like Mojang's (a 1.21 and a 1.8.9), with the files they name made up in
//! a scratch folder.
use super::*;
use serde_json::json;
use std::io::Write;
fn scratch(name: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("horizon-java-launch-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
dir
}
/// `rel` (`a/b/c`) under `base`, one part at a time as the code joins them (Windows paths).
fn under(base: &Path, rel: &str) -> PathBuf {
rel.split('/')
.fold(base.to_path_buf(), |path, part| path.join(part))
}
fn player() -> Player {
Player {
name: "Notch".into(),
uuid: "069a79f444e94726a5befca90e38aaf5".into(),
token: "mc-token".into(),
xuid: "2535400000000000".into(),
}
}
fn args(cmd: &Command) -> Vec<String> {
cmd.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect()
}
fn modern() -> Value {
json!({
"id": "1.21.4", "type": "release", "mainClass": "net.minecraft.client.main.Main",
"assetIndex": {"id": "19"},
"arguments": {
"game": ["--username", "${auth_player_name}", "--version", "${version_name}",
"--gameDir", "${game_directory}", "--assetsDir", "${assets_root}",
"--assetIndex", "${assets_index_name}", "--uuid", "${auth_uuid}",
"--accessToken", "${auth_access_token}", "--xuid", "${auth_xuid}",
"--userType", "${user_type}",
{"rules": [{"action": "allow", "features": {"is_demo_user": true}}], "value": "--demo"},
{"rules": [{"action": "allow", "features": {"has_custom_resolution": true}}],
"value": ["--width", "${resolution_width}"]}],
"jvm": [{"rules": [{"action": "allow", "os": {"name": "osx"}}], "value": ["-XstartOnFirstThread"]},
{"rules": [{"action": "allow", "os": {"name": "windows", "arch": "x86"}}], "value": "-Xss1M"},
"-Djava.library.path=${natives_directory}", "-Dminecraft.launcher.brand=${launcher_name}",
"-cp", "${classpath}"]
},
"logging": {"client": {"argument": "-Dlog4j.configurationFile=${path}",
"file": {"id": "client-1.21.2.xml"}}},
"libraries": [
{"name": "a:common:1", "downloads": {"artifact": {"path": "a/common/1/common-1.jar"}}},
{"name": "a:mac:1", "downloads": {"artifact": {"path": "a/mac/1/mac-1.jar"}},
"rules": [{"action": "allow", "os": {"name": "osx"}}]},
{"name": "a:not-mac:1", "downloads": {"artifact": {"path": "a/not-mac/1/not-mac-1.jar"}},
"rules": [{"action": "allow"}, {"action": "disallow", "os": {"name": "osx"}}]},
{"name": "a:common:1-again", "downloads": {"artifact": {"path": "a/common/1/common-1.jar"}}}
]
})
}
#[test]
fn a_modern_version_gets_its_rules_placeholders_and_classpath() {
let dir = scratch("modern");
let (mc, game, natives) = (dir.join("mc"), dir.join("game"), dir.join("natives"));
let java = dir.join("java");
let p = player();
let extra = vec!["-XX:+UseZGC".to_string()];
let launch = |os| Launch {
minecraft: &mc,
game_dir: &game,
natives: &natives,
java: &java,
memory_mb: 6144,
jvm_args: &extra,
player: &p,
os,
};
let cmd = command(&modern(), &launch("osx")).unwrap();
let a = args(&cmd);
let at = |s: &str| {
a.iter()
.position(|x| x == s)
.unwrap_or_else(|| panic!("no {s} in {a:?}"))
};
assert_eq!(a[0], "-Xmx6144M");
assert!(a.contains(&"-Dlog4j2.formatMsgNoLookups=true".into()));
let config = under(&mc, "assets/log_configs/client-1.21.2.xml");
assert!(a.contains(&format!("-Dlog4j.configurationFile={}", config.display())));
assert!(at("-XstartOnFirstThread") < at("net.minecraft.client.main.Main"));
assert!(at("-XX:+UseZGC") < at("net.minecraft.client.main.Main"));
assert!(a.contains(&"-Dminecraft.launcher.brand=horizon-launcher".into()));
assert_eq!(a[at("--username") + 1], "Notch");
assert_eq!(a[at("--accessToken") + 1], "mc-token");
assert_eq!(a[at("--assetIndex") + 1], "19");
assert_eq!(a[at("--gameDir") + 1], game.to_string_lossy());
assert_eq!(a[at("--userType") + 1], "msa");
// Features are off: no demo, no custom size.
assert!(!a.iter().any(|x| x == "--demo" || x == "--width"));
let sep = if cfg!(windows) { ";" } else { ":" };
let cp: Vec<String> = a[at("-cp") + 1].split(sep).map(String::from).collect();
// Library paths are Mojang's, `/` and all (`mojang::relative`): Windows takes them.
let lib = |p: &str| mc.join("libraries").join(p).to_string_lossy().into_owned();
assert_eq!(
cp,
[
lib("a/common/1/common-1.jar"),
lib("a/mac/1/mac-1.jar"),
under(&mc, "versions/1.21.4/1.21.4.jar")
.to_string_lossy()
.into_owned()
]
);
assert_eq!(cmd.get_current_dir(), Some(game.as_path()));
let linux = args(&command(&modern(), &launch("linux")).unwrap());
assert!(!linux.contains(&"-XstartOnFirstThread".into()));
assert!(linux.iter().any(|x| x.contains("not-mac-1.jar")));
// The x86-only rule holds on 32-bit Windows alone.
let windows = args(&command(&modern(), &launch("windows")).unwrap());
assert_eq!(
windows.contains(&"-Xss1M".into()),
cfg!(target_arch = "x86")
);
let _ = fs::remove_dir_all(&dir);
}
/// A 1.8.9-shaped version: `minecraftArguments`, natives unpacked without META-INF, and a
/// virtual asset index laid out by name.
#[test]
fn an_old_version_gets_its_natives_and_assets_by_name() {
let dir = scratch("old");
let (mc, game, natives) = (dir.join("mc"), dir.join("game"), dir.join("natives"));
let jar = mc.join("libraries/org/lwjgl/lwjgl-platform-2.9.4-natives-osx.jar");
fs::create_dir_all(jar.parent().unwrap()).unwrap();
let mut zip = zip::ZipWriter::new(fs::File::create(&jar).unwrap());
let opts = zip::write::SimpleFileOptions::default();
for (name, body) in [
("liblwjgl.dylib", b"lib".as_slice()),
("META-INF/MANIFEST.MF", b"m"),
] {
zip.start_file(name, opts).unwrap();
zip.write_all(body).unwrap();
}
zip.finish().unwrap();
let hash = "0123456789abcdef0123456789abcdef01234567";
let object = mc.join("assets/objects/01").join(hash);
fs::create_dir_all(object.parent().unwrap()).unwrap();
fs::write(&object, b"ogg").unwrap();
fs::create_dir_all(mc.join("assets/indexes")).unwrap();
fs::write(
mc.join("assets/indexes/1.8.json"),
json!({"virtual": true, "objects": {"sounds/step/grass1.ogg": {"hash": hash, "size": 3}}})
.to_string(),
)
.unwrap();
let version = json!({
"id": "1.8.9", "type": "release", "mainClass": "net.minecraft.client.main.Main",
"assetIndex": {"id": "1.8"},
"minecraftArguments": "--username ${auth_player_name} --session ${auth_session} --assetsDir ${game_assets} --userProperties ${user_properties} --unknown ${nothing}",
"libraries": [{
"name": "org.lwjgl.lwjgl:lwjgl-platform:2.9.4",
"natives": {"osx": "natives-osx"},
"extract": {"exclude": ["META-INF/"]},
"downloads": {"classifiers": {"natives-osx": {"path": "org/lwjgl/lwjgl-platform-2.9.4-natives-osx.jar"}}}
}]
});
let (java, p) = (dir.join("java"), player());
let launch = Launch {
minecraft: &mc,
game_dir: &game,
natives: &natives,
java: &java,
memory_mb: 2048,
jvm_args: &[],
player: &p,
os: "osx",
};
let a = args(&command(&version, &launch).unwrap());
assert_eq!(fs::read(natives.join("liblwjgl.dylib")).unwrap(), b"lib");
assert!(!natives.join("META-INF").exists());
let virtual_dir = under(&mc, "assets/virtual/1.8");
assert_eq!(
fs::read(virtual_dir.join("sounds/step/grass1.ogg")).unwrap(),
b"ogg"
);
let at = |s: &str| a.iter().position(|x| x == s).unwrap();
assert_eq!(a[at("--assetsDir") + 1], virtual_dir.to_string_lossy());
assert_eq!(
a[at("--session") + 1],
"token:mc-token:069a79f444e94726a5befca90e38aaf5"
);
assert_eq!(a[at("--userProperties") + 1], "{}");
assert_eq!(a[at("--unknown") + 1], "${nothing}");
assert_eq!(a[0], "-Xmx2048M");
assert!(a.contains(&format!("-Djava.library.path={}", natives.display())));
// Natives are not on the classpath; the client jar is.
let cp = &a[at("-cp") + 1];
assert!(cp.ends_with("1.8.9.jar") && !cp.contains("natives"), "{cp}");
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn placeholders_fill_in_place() {
let vars = HashMap::from([("a", "1".to_string()), ("b", "two".to_string())]);
assert_eq!(fill("x${a}y${b}z", &vars), "x1ytwoz");
assert_eq!(fill("${c} ${a", &vars), "${c} ${a");
}
+1
View File
@@ -15,6 +15,7 @@ pub mod i18n;
pub mod import;
pub mod instances;
pub mod java;
pub mod java_launch;
pub mod launch;
pub mod minecraft_versions;
pub mod modpacks;
+1 -1
View File
@@ -268,7 +268,7 @@ fn text(v: &Value, key: &str) -> Result<String, String> {
}
/// A path from the version file, refused if it could leave the folder.
fn relative(path: &str) -> Result<PathBuf, String> {
pub(crate) fn relative(path: &str) -> Result<PathBuf, String> {
let safe = !path.contains('\\')
&& Path::new(path)
.components()