From 50e31f38bbb368ec87f2b58aa5ec77d710b32678 Mon Sep 17 00:00:00 2001 From: objz Date: Wed, 20 May 2026 16:00:22 +0200 Subject: [PATCH 01/12] fix(config) default config now uses the rmcl name instead the old mcl --- assets/config.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/assets/config.toml b/assets/config.toml index 0ceed75..d05cd51 100644 --- a/assets/config.toml +++ b/assets/config.toml @@ -1,9 +1,9 @@ [paths] # where instances are stored -instances_dir = "~/.local/share/mcl/instances" +instances_dir = "~/.local/share/rmcl/instances" # metadata, cache, whatever else ends up there -meta_dir = "~/.local/share/mcl/meta" +meta_dir = "~/.local/share/rmcl/meta" # optional java override (empty = uses JAVA_HOME) java_path = "" From 22e4481a2b6ab20b7d9511f038592c8b75e346a5 Mon Sep 17 00:00:00 2001 From: objz Date: Mon, 25 May 2026 14:02:18 +0200 Subject: [PATCH 02/12] added: launch_profile foundation primitives (rules, templates, model) introduce the launch_profile module with the core types and pure functions needed to parse, evaluate, and substitute against mojang-format version JSON: Rule/OsCondition/FeatureSet with a single rule evaluator, TemplateContext + substitute() for ${var} expansion, and the LaunchProfile schema mirroring mojang's on-disk shape. no consumers yet. --- src/launch_profile/mod.rs | 10 ++ src/launch_profile/model.rs | 278 +++++++++++++++++++++++++++++ src/launch_profile/rules.rs | 302 ++++++++++++++++++++++++++++++++ src/launch_profile/templates.rs | 262 +++++++++++++++++++++++++++ src/main.rs | 1 + 5 files changed, 853 insertions(+) create mode 100644 src/launch_profile/mod.rs create mode 100644 src/launch_profile/model.rs create mode 100644 src/launch_profile/rules.rs create mode 100644 src/launch_profile/templates.rs diff --git a/src/launch_profile/mod.rs b/src/launch_profile/mod.rs new file mode 100644 index 0000000..0538de4 --- /dev/null +++ b/src/launch_profile/mod.rs @@ -0,0 +1,10 @@ +// foundation primitives for parsing, merging, and rendering mojang-format +// launch profiles. consumed by the vanilla launcher, forge/neoforge, +// fabric/quilt - anything that reads a mojang-style version JSON. +// +// phase 1 is pure additions: no consumers yet. later phases will wire +// these primitives into the launch pipeline and the installer paths. + +pub mod model; +pub mod rules; +pub mod templates; diff --git a/src/launch_profile/model.rs b/src/launch_profile/model.rs new file mode 100644 index 0000000..822aa39 --- /dev/null +++ b/src/launch_profile/model.rs @@ -0,0 +1,278 @@ +// mojang-format launch profile types. mirrors the on-disk JSON schema +// used by vanilla versions, forge installer output, neoforge installer +// output, fabric profiles, and quilt profiles. parsing is lossless for +// the fields we care about; unknown fields are silently dropped (serde +// default behavior) - which is fine because we write upstream JSON +// byte-for-byte on the install side. +// +// no consumers yet. phase 2 wires this into the vanilla launch path; +// phase 3 adds inheritsFrom resolution; phase 4 swaps the forge/neoforge +// installer profile writer over to use this shape. + +use serde::{Deserialize, Serialize}; + +use super::rules::Rule; + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LaunchProfile { + pub id: String, + pub inherits_from: Option, + pub main_class: Option, + #[serde(default)] + pub libraries: Vec, + pub arguments: Option, + pub minecraft_arguments: Option, + pub asset_index: Option, + pub assets: Option, + pub java_version: Option, + pub downloads: Option, + pub release_time: Option, + pub time: Option, + #[serde(rename = "type")] + pub type_: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)] +pub struct Arguments { + #[serde(default)] + pub game: Vec, + #[serde(default)] + pub jvm: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(untagged)] +pub enum Argument { + Literal(String), + Conditional { + rules: Vec, + value: ArgumentValue, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(untagged)] +pub enum ArgumentValue { + Single(String), + Multiple(Vec), +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct Library { + pub name: String, + pub downloads: Option, + pub rules: Option>, + pub url: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct LibraryDownloads { + pub artifact: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct Artifact { + pub url: String, + pub path: String, + pub sha1: String, + pub size: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct AssetIndex { + pub id: String, + pub url: String, + pub sha1: String, + pub size: Option, + pub total_size: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JavaVersion { + pub component: Option, + pub major_version: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct VersionDownloads { + pub client: Download, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct Download { + pub url: String, + pub sha1: String, + pub size: u64, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::launch_profile::rules::RuleAction; + + const MODERN_FIXTURE: &str = r#"{ + "id": "1.20.1", + "type": "release", + "mainClass": "net.minecraft.client.main.Main", + "assetIndex": { + "id": "5", + "url": "https://example.invalid/5.json", + "sha1": "0000000000000000000000000000000000000000" + }, + "javaVersion": { + "component": "java-runtime-gamma", + "majorVersion": 17 + }, + "libraries": [ + { + "name": "org.lwjgl:lwjgl:3.3.1", + "downloads": { + "artifact": { + "url": "https://example.invalid/lwjgl.jar", + "path": "org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1.jar", + "sha1": "1111111111111111111111111111111111111111", + "size": 100 + } + }, + "rules": [ + { "action": "allow", "os": { "name": "linux" } } + ] + } + ], + "arguments": { + "game": [ + "--username", "${auth_player_name}", + { + "rules": [{ "action": "allow", "features": { "is_demo_user": true } }], + "value": "--demo" + } + ], + "jvm": [ + "-Djava.library.path=${natives_directory}", + { + "rules": [{ "action": "allow", "os": { "name": "osx" } }], + "value": ["-XstartOnFirstThread"] + } + ] + } + }"#; + + const LEGACY_FIXTURE: &str = r#"{ + "id": "1.7.10", + "type": "release", + "mainClass": "net.minecraft.client.main.Main", + "minecraftArguments": "--username ${auth_player_name} --version ${version_name} --gameDir ${game_directory}", + "assetIndex": { + "id": "1.7.10", + "url": "https://example.invalid/1.7.10.json", + "sha1": "0000000000000000000000000000000000000000" + }, + "libraries": [] + }"#; + + const LOADER_FIXTURE: &str = r#"{ + "id": "1.20.1-forge-47.2.0", + "inheritsFrom": "1.20.1", + "mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher", + "libraries": [ + { "name": "net.minecraftforge:forge:47.2.0" } + ], + "arguments": { + "game": ["--launchTarget", "forge_client"], + "jvm": [ + "--add-opens", "java.base/sun.security.util=cpw.mods.securejarhandler", + "-DlibraryDirectory=${library_directory}" + ] + } + }"#; + + #[test] + fn parses_modern_arguments_object() { + let profile: LaunchProfile = serde_json::from_str(MODERN_FIXTURE).unwrap(); + assert_eq!(profile.id, "1.20.1"); + assert_eq!( + profile.main_class.as_deref(), + Some("net.minecraft.client.main.Main") + ); + assert!(profile.inherits_from.is_none()); + assert!(profile.minecraft_arguments.is_none()); + + let args = profile.arguments.as_ref().expect("arguments present"); + assert_eq!(args.game.len(), 3); + assert_eq!(args.jvm.len(), 2); + + // first game arg should be a literal "--username" + match &args.game[0] { + Argument::Literal(s) => assert_eq!(s, "--username"), + _ => panic!("expected literal"), + } + // third game arg should be a conditional with a single-string value + match &args.game[2] { + Argument::Conditional { rules, value } => { + assert_eq!(rules.len(), 1); + assert_eq!(rules[0].action, RuleAction::Allow); + assert!(matches!(value, ArgumentValue::Single(_))); + } + _ => panic!("expected conditional"), + } + // second jvm arg should be a conditional with a multi-string value + match &args.jvm[1] { + Argument::Conditional { value, .. } => { + assert!(matches!(value, ArgumentValue::Multiple(_))); + } + _ => panic!("expected conditional"), + } + } + + #[test] + fn parses_legacy_minecraft_arguments_string() { + let profile: LaunchProfile = serde_json::from_str(LEGACY_FIXTURE).unwrap(); + assert_eq!(profile.id, "1.7.10"); + assert!(profile.arguments.is_none()); + assert!( + profile + .minecraft_arguments + .as_deref() + .unwrap() + .contains("${version_name}") + ); + assert!(profile.libraries.is_empty()); + } + + #[test] + fn parses_loader_profile_with_inherits_from() { + let profile: LaunchProfile = serde_json::from_str(LOADER_FIXTURE).unwrap(); + assert_eq!(profile.id, "1.20.1-forge-47.2.0"); + assert_eq!(profile.inherits_from.as_deref(), Some("1.20.1")); + assert!(profile.asset_index.is_none()); // inherited from parent + let args = profile.arguments.as_ref().unwrap(); + assert_eq!(args.game.len(), 2); + assert_eq!(args.jvm.len(), 3); + } + + #[test] + fn modern_profile_round_trips() { + let original: LaunchProfile = serde_json::from_str(MODERN_FIXTURE).unwrap(); + let serialized = serde_json::to_string(&original).unwrap(); + let reparsed: LaunchProfile = serde_json::from_str(&serialized).unwrap(); + assert_eq!(original, reparsed); + } + + #[test] + fn loader_profile_round_trips() { + let original: LaunchProfile = serde_json::from_str(LOADER_FIXTURE).unwrap(); + let serialized = serde_json::to_string(&original).unwrap(); + let reparsed: LaunchProfile = serde_json::from_str(&serialized).unwrap(); + assert_eq!(original, reparsed); + } + + #[test] + fn legacy_profile_round_trips() { + let original: LaunchProfile = serde_json::from_str(LEGACY_FIXTURE).unwrap(); + let serialized = serde_json::to_string(&original).unwrap(); + let reparsed: LaunchProfile = serde_json::from_str(&serialized).unwrap(); + assert_eq!(original, reparsed); + } +} diff --git a/src/launch_profile/rules.rs b/src/launch_profile/rules.rs new file mode 100644 index 0000000..c507773 --- /dev/null +++ b/src/launch_profile/rules.rs @@ -0,0 +1,302 @@ +// mojang rule evaluation. profiles, libraries, and argument entries can +// carry conditional rules that filter them by OS, architecture, or feature +// flags. this module is the single source of truth for that semantics - +// see `evaluate` below for the exact rules. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum RuleAction { + Allow, + Disallow, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)] +pub struct OsCondition { + pub name: Option, + pub arch: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct FeatureSet { + pub is_demo_user: Option, + pub has_custom_resolution: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct Rule { + pub action: RuleAction, + pub os: Option, + pub features: Option, +} + +pub struct RuleContext<'a> { + pub os_name: &'a str, + pub arch: &'a str, + pub features: &'a FeatureSet, +} + +pub fn evaluate(rules: &[Rule], ctx: &RuleContext) -> bool { + if rules.is_empty() { + return true; + } + let mut allowed = false; + for rule in rules { + if rule_matches(rule, ctx) { + allowed = matches!(rule.action, RuleAction::Allow); + } + } + allowed +} + +fn rule_matches(rule: &Rule, ctx: &RuleContext) -> bool { + if let Some(os) = &rule.os { + if let Some(name) = &os.name + && name != ctx.os_name + { + return false; + } + if let Some(arch) = &os.arch + && arch != ctx.arch + { + return false; + } + } + if let Some(required) = &rule.features + && !features_match(required, ctx.features) + { + return false; + } + true +} + +fn features_match(required: &FeatureSet, current: &FeatureSet) -> bool { + if let Some(want) = required.is_demo_user + && current.is_demo_user.unwrap_or(false) != want + { + return false; + } + if let Some(want) = required.has_custom_resolution + && current.has_custom_resolution.unwrap_or(false) != want + { + return false; + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + + fn linux_ctx<'a>(features: &'a FeatureSet) -> RuleContext<'a> { + RuleContext { + os_name: "linux", + arch: "x86_64", + features, + } + } + + #[test] + fn empty_rules_allow() { + let features = FeatureSet::default(); + let ctx = linux_ctx(&features); + assert!(evaluate(&[], &ctx)); + } + + #[test] + fn single_allow_matching_os_allows() { + let rules = vec![Rule { + action: RuleAction::Allow, + os: Some(OsCondition { + name: Some("linux".into()), + arch: None, + }), + features: None, + }]; + let features = FeatureSet::default(); + let ctx = linux_ctx(&features); + assert!(evaluate(&rules, &ctx)); + } + + #[test] + fn single_disallow_matching_os_disallows() { + let rules = vec![Rule { + action: RuleAction::Disallow, + os: Some(OsCondition { + name: Some("linux".into()), + arch: None, + }), + features: None, + }]; + let features = FeatureSet::default(); + let ctx = linux_ctx(&features); + assert!(!evaluate(&rules, &ctx)); + } + + #[test] + fn allow_without_os_match_disallows_by_default() { + // explicit allow for windows; we are on linux; nothing matches; + // default state remains disallow. + let rules = vec![Rule { + action: RuleAction::Allow, + os: Some(OsCondition { + name: Some("windows".into()), + arch: None, + }), + features: None, + }]; + let features = FeatureSet::default(); + let ctx = linux_ctx(&features); + assert!(!evaluate(&rules, &ctx)); + } + + #[test] + fn last_matching_rule_wins_when_allow_then_disallow() { + let rules = vec![ + Rule { + action: RuleAction::Allow, + os: None, + features: None, + }, + Rule { + action: RuleAction::Disallow, + os: Some(OsCondition { + name: Some("osx".into()), + arch: None, + }), + features: None, + }, + ]; + let features = FeatureSet::default(); + let osx_ctx = RuleContext { + os_name: "osx", + arch: "x86_64", + features: &features, + }; + assert!(!evaluate(&rules, &osx_ctx)); + // and on linux: only the first rule matches → still allow. + let lin = linux_ctx(&features); + assert!(evaluate(&rules, &lin)); + } + + #[test] + fn last_matching_rule_wins_when_disallow_then_allow() { + let rules = vec![ + Rule { + action: RuleAction::Disallow, + os: None, + features: None, + }, + Rule { + action: RuleAction::Allow, + os: Some(OsCondition { + name: Some("linux".into()), + arch: None, + }), + features: None, + }, + ]; + let features = FeatureSet::default(); + let ctx = linux_ctx(&features); + assert!(evaluate(&rules, &ctx)); + } + + #[test] + fn arch_mismatch_blocks_rule() { + let rules = vec![Rule { + action: RuleAction::Allow, + os: Some(OsCondition { + name: Some("linux".into()), + arch: Some("arm64".into()), + }), + features: None, + }]; + let features = FeatureSet::default(); + let ctx = linux_ctx(&features); + // we are on linux but x86_64, not arm64 → rule does not match. + assert!(!evaluate(&rules, &ctx)); + } + + #[test] + fn feature_required_true_matches_when_ctx_true() { + let rules = vec![Rule { + action: RuleAction::Allow, + os: None, + features: Some(FeatureSet { + is_demo_user: Some(true), + has_custom_resolution: None, + }), + }]; + let demo_features = FeatureSet { + is_demo_user: Some(true), + has_custom_resolution: None, + }; + let ctx = RuleContext { + os_name: "linux", + arch: "x86_64", + features: &demo_features, + }; + assert!(evaluate(&rules, &ctx)); + } + + #[test] + fn feature_required_true_blocks_when_ctx_false() { + let rules = vec![Rule { + action: RuleAction::Allow, + os: None, + features: Some(FeatureSet { + is_demo_user: Some(true), + has_custom_resolution: None, + }), + }]; + let features = FeatureSet::default(); + let ctx = linux_ctx(&features); + assert!(!evaluate(&rules, &ctx)); + } + + #[test] + fn no_os_no_features_rule_always_matches() { + let rules = vec![Rule { + action: RuleAction::Allow, + os: None, + features: None, + }]; + let features = FeatureSet::default(); + let ctx = linux_ctx(&features); + assert!(evaluate(&rules, &ctx)); + } + + #[test] + fn rule_deserializes_from_mojang_json() { + // shape lifted from real mojang library rules. + let json = r#"{ + "action": "allow", + "os": { "name": "osx" } + }"#; + let rule: Rule = serde_json::from_str(json).unwrap(); + assert_eq!(rule.action, RuleAction::Allow); + assert_eq!( + rule.os.as_ref().and_then(|o| o.name.as_deref()), + Some("osx") + ); + assert!(rule.os.as_ref().and_then(|o| o.arch.as_ref()).is_none()); + assert!(rule.features.is_none()); + } + + #[test] + fn rule_deserializes_with_features() { + let json = r#"{ + "action": "allow", + "features": { "is_demo_user": true } + }"#; + let rule: Rule = serde_json::from_str(json).unwrap(); + assert_eq!(rule.action, RuleAction::Allow); + assert!(rule.os.is_none()); + assert_eq!( + rule.features.as_ref().and_then(|f| f.is_demo_user), + Some(true) + ); + } +} diff --git a/src/launch_profile/templates.rs b/src/launch_profile/templates.rs new file mode 100644 index 0000000..08fa885 --- /dev/null +++ b/src/launch_profile/templates.rs @@ -0,0 +1,262 @@ +// template substitution for mojang-style launch arguments. profiles use +// `${variable_name}` placeholders that the launcher fills in at launch +// time from the active session: paths, the user's account info, the +// classpath, the resolved natives directory, and so on. +// +// the full set of variables is documented in `TemplateContext`. unknown +// placeholders are left as-is and logged at `warn` level - that way if +// mojang adds a new variable in the future, the launcher fails open +// rather than silently swallowing it. + +use std::path::Path; + +pub struct TemplateContext<'a> { + pub library_directory: &'a Path, + pub classpath_separator: &'a str, + pub version_name: &'a str, + pub natives_directory: &'a Path, + pub classpath: &'a str, + pub game_directory: &'a Path, + pub assets_root: &'a Path, + pub assets_index_name: &'a str, + pub auth_player_name: &'a str, + pub auth_uuid: &'a str, + pub auth_access_token: &'a str, + pub auth_xuid: &'a str, + pub user_type: &'a str, + pub user_properties: &'a str, + pub launcher_name: &'a str, + pub launcher_version: &'a str, + pub clientid: &'a str, +} + +pub fn substitute(input: &str, ctx: &TemplateContext) -> String { + let mut out = String::with_capacity(input.len()); + let mut rest = input; + while let Some(open) = rest.find("${") { + out.push_str(&rest[..open]); + let after_open = &rest[open + 2..]; + match after_open.find('}') { + Some(close_rel) => { + let name = &after_open[..close_rel]; + match lookup(name, ctx) { + Some(value) => out.push_str(&value), + None => { + tracing::warn!("unknown launch template variable: ${{{}}}", name); + out.push_str("${"); + out.push_str(name); + out.push('}'); + } + } + rest = &after_open[close_rel + 1..]; + } + None => { + // unclosed `${...` - emit the rest literally and stop. + out.push_str("${"); + out.push_str(after_open); + return out; + } + } + } + out.push_str(rest); + out +} + +fn lookup(name: &str, ctx: &TemplateContext) -> Option { + Some(match name { + "library_directory" => ctx.library_directory.display().to_string(), + "classpath_separator" => ctx.classpath_separator.to_string(), + "version_name" => ctx.version_name.to_string(), + "natives_directory" => ctx.natives_directory.display().to_string(), + "classpath" => ctx.classpath.to_string(), + "game_directory" => ctx.game_directory.display().to_string(), + "assets_root" => ctx.assets_root.display().to_string(), + "assets_index_name" => ctx.assets_index_name.to_string(), + "auth_player_name" => ctx.auth_player_name.to_string(), + "auth_uuid" => ctx.auth_uuid.to_string(), + "auth_access_token" => ctx.auth_access_token.to_string(), + "auth_xuid" => ctx.auth_xuid.to_string(), + "user_type" => ctx.user_type.to_string(), + "user_properties" => ctx.user_properties.to_string(), + "launcher_name" => ctx.launcher_name.to_string(), + "launcher_version" => ctx.launcher_version.to_string(), + "clientid" => ctx.clientid.to_string(), + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn ctx_fixture<'a>( + library_directory: &'a Path, + natives_directory: &'a Path, + game_directory: &'a Path, + assets_root: &'a Path, + ) -> TemplateContext<'a> { + TemplateContext { + library_directory, + classpath_separator: ":", + version_name: "1.20.1", + natives_directory, + classpath: "a.jar:b.jar", + game_directory, + assets_root, + assets_index_name: "5", + auth_player_name: "Player", + auth_uuid: "00000000-0000-0000-0000-000000000000", + auth_access_token: "token", + auth_xuid: "0", + user_type: "msa", + user_properties: "{}", + launcher_name: "rmcl", + launcher_version: "0.3.0", + clientid: "0", + } + } + + #[test] + fn no_placeholders_unchanged() { + let lib = PathBuf::from("/m/libraries"); + let nat = PathBuf::from("/m/natives"); + let game = PathBuf::from("/i/.minecraft"); + let assets = PathBuf::from("/m/assets"); + let ctx = ctx_fixture(&lib, &nat, &game, &assets); + assert_eq!( + substitute("--add-modules ALL-MODULE-PATH", &ctx), + "--add-modules ALL-MODULE-PATH" + ); + } + + #[test] + fn single_known_substitution() { + let lib = PathBuf::from("/m/libraries"); + let nat = PathBuf::from("/m/natives"); + let game = PathBuf::from("/i/.minecraft"); + let assets = PathBuf::from("/m/assets"); + let ctx = ctx_fixture(&lib, &nat, &game, &assets); + assert_eq!(substitute("v=${version_name}", &ctx), "v=1.20.1"); + } + + #[test] + fn unknown_placeholder_left_as_is() { + let lib = PathBuf::from("/m/libraries"); + let nat = PathBuf::from("/m/natives"); + let game = PathBuf::from("/i/.minecraft"); + let assets = PathBuf::from("/m/assets"); + let ctx = ctx_fixture(&lib, &nat, &game, &assets); + assert_eq!( + substitute("x=${not_a_real_var}y", &ctx), + "x=${not_a_real_var}y" + ); + } + + #[test] + fn unclosed_placeholder_left_as_is() { + let lib = PathBuf::from("/m/libraries"); + let nat = PathBuf::from("/m/natives"); + let game = PathBuf::from("/i/.minecraft"); + let assets = PathBuf::from("/m/assets"); + let ctx = ctx_fixture(&lib, &nat, &game, &assets); + assert_eq!( + substitute("--prefix ${unclosed", &ctx), + "--prefix ${unclosed" + ); + } + + #[test] + fn dollar_without_brace_is_literal() { + let lib = PathBuf::from("/m/libraries"); + let nat = PathBuf::from("/m/natives"); + let game = PathBuf::from("/i/.minecraft"); + let assets = PathBuf::from("/m/assets"); + let ctx = ctx_fixture(&lib, &nat, &game, &assets); + assert_eq!(substitute("$$ literal $5 $", &ctx), "$$ literal $5 $"); + } + + #[test] + fn multiple_substitutions() { + let lib = PathBuf::from("/m/libraries"); + let nat = PathBuf::from("/m/natives"); + let game = PathBuf::from("/i/.minecraft"); + let assets = PathBuf::from("/m/assets"); + let ctx = ctx_fixture(&lib, &nat, &game, &assets); + assert_eq!( + substitute("${version_name}-${auth_player_name}", &ctx), + "1.20.1-Player" + ); + } + + #[test] + fn path_substitution() { + let lib = PathBuf::from("/m/libraries"); + let nat = PathBuf::from("/m/natives"); + let game = PathBuf::from("/i/.minecraft"); + let assets = PathBuf::from("/m/assets"); + let ctx = ctx_fixture(&lib, &nat, &game, &assets); + assert_eq!( + substitute("-DlibraryDirectory=${library_directory}", &ctx), + "-DlibraryDirectory=/m/libraries" + ); + } + + #[test] + fn substituted_value_is_not_recursively_substituted() { + // simulate a `user_properties` value that happens to contain a `${...}` + // pattern. it should NOT trigger another substitution pass. + let lib = PathBuf::from("/m/libraries"); + let nat = PathBuf::from("/m/natives"); + let game = PathBuf::from("/i/.minecraft"); + let assets = PathBuf::from("/m/assets"); + let ctx = TemplateContext { + library_directory: &lib, + classpath_separator: ":", + version_name: "1.20.1", + natives_directory: &nat, + classpath: "a.jar:b.jar", + game_directory: &game, + assets_root: &assets, + assets_index_name: "5", + auth_player_name: "Player", + auth_uuid: "00000000-0000-0000-0000-000000000000", + auth_access_token: "token", + auth_xuid: "0", + user_type: "msa", + user_properties: "${version_name}", + launcher_name: "rmcl", + launcher_version: "0.3.0", + clientid: "0", + }; + assert_eq!(substitute("${user_properties}", &ctx), "${version_name}"); + } + + #[test] + fn empty_input_is_empty() { + let lib = PathBuf::from("/m/libraries"); + let nat = PathBuf::from("/m/natives"); + let game = PathBuf::from("/i/.minecraft"); + let assets = PathBuf::from("/m/assets"); + let ctx = ctx_fixture(&lib, &nat, &game, &assets); + assert_eq!(substitute("", &ctx), ""); + } + + #[test] + fn windows_style_backslashes_in_value_pass_through() { + // simulate a Windows install where library_directory is a path with + // backslashes. the substitution must not interpret backslashes as + // escape sequences or do anything else clever - it just copies the + // value into the output. + let lib = PathBuf::from(r"C:\Users\test\.minecraft\libraries"); + let nat = PathBuf::from(r"C:\Users\test\.minecraft\natives"); + let game = PathBuf::from(r"C:\Users\test\.minecraft"); + let assets = PathBuf::from(r"C:\Users\test\.minecraft\assets"); + let ctx = ctx_fixture(&lib, &nat, &game, &assets); + let result = substitute("-Dpath=${library_directory}", &ctx); + assert!( + result.contains(r"C:\Users\test\.minecraft\libraries"), + "expected backslashes preserved, got: {result}" + ); + } +} diff --git a/src/main.rs b/src/main.rs index 1179e54..c9cd4b6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod cli; pub mod config; pub mod instance; pub mod instance_logs; +pub mod launch_profile; mod migrate; pub mod net; pub mod running; From 2029005c7861488c9ad36e884641bb1a96e21205 Mon Sep 17 00:00:00 2001 From: objz Date: Mon, 25 May 2026 14:51:34 +0200 Subject: [PATCH 03/12] added: vanilla launch flow uses LaunchProfile with render + raw-byte storage vanilla launches now read upstream mojang JSON via the new LaunchProfile path, render game/JVM args through launch_profile::render with template substitution, and store the cached meta.json byte-for-byte instead of re-serializing through a narrow struct. legacy stripped meta.json files are re-fetched on first launch. adds HttpClient::get_bytes and fetch_version_meta_with_raw; collapses the two library-rule evaluators into one shared launch_profile::rules::evaluate. --- src/instance/launch/mod.rs | 435 +++++++++++++++++++++-------------- src/instance/manager.rs | 20 +- src/launch_profile/mod.rs | 5 +- src/launch_profile/render.rs | 318 +++++++++++++++++++++++++ src/net/mod.rs | 4 + src/net/mojang.rs | 84 +++---- 6 files changed, 629 insertions(+), 237 deletions(-) create mode 100644 src/launch_profile/render.rs diff --git a/src/instance/launch/mod.rs b/src/instance/launch/mod.rs index 5fa9483..a366d72 100644 --- a/src/instance/launch/mod.rs +++ b/src/instance/launch/mod.rs @@ -15,6 +15,8 @@ use crate::instance::models::{InstanceConfig, ModLoader}; pub enum LaunchError { #[error("Version metadata not found: {0}. Re-create the instance to fix this.")] MetaNotFound(String), + #[error("Profile error: {0}")] + Parse(String), #[error("IO error: {0}")] Io(#[from] std::io::Error), #[error("JSON error: {0}")] @@ -25,47 +27,6 @@ pub enum LaunchError { Auth(String), } -// subset of mojang's version meta json, only the bits relevant to launching -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct MetaJson { - main_class: String, - asset_index: MetaAssetIndex, - libraries: Vec, -} - -#[derive(serde::Deserialize)] -struct MetaAssetIndex { - id: String, -} - -#[derive(serde::Deserialize)] -struct MetaLibrary { - downloads: Option, - rules: Option>, -} - -#[derive(serde::Deserialize)] -struct MetaLibraryDownloads { - artifact: Option, -} - -#[derive(serde::Deserialize)] -struct MetaArtifact { - path: String, -} - -#[derive(serde::Deserialize)] -struct MetaRule { - action: String, - os: Option, -} - -#[derive(serde::Deserialize)] -struct MetaOsRule { - name: Option, -} - #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase")] struct LoaderProfileJson { @@ -80,78 +41,65 @@ struct LoaderLibrary { name: String, } -struct GameAuth { - username: String, - uuid: String, - token: String, - user_type: String, -} - fn account_can_launch(has_microsoft_account: bool, account: &Account) -> bool { account.account_type == AccountType::Microsoft || has_microsoft_account } -// mojang's library rules are a fun little state machine: each rule can allow -// or disallow based on OS. if no rule matches the current OS, the library is -// included only if no rule "dominated" (matched at all). yes, it's weird. -fn lib_allowed(lib: &MetaLibrary) -> bool { - let Some(rules) = &lib.rules else { - return true; - }; - let current_os = match std::env::consts::OS { - "macos" => "osx", - other => other, - }; - let mut dominated = false; - for rule in rules { - let matches_os = rule - .os - .as_ref() - .and_then(|os| os.name.as_deref()) - .is_none_or(|n| n == current_os); - if !matches_os { - continue; - } - dominated = true; - match rule.action.as_str() { - "disallow" => return false, - "allow" => return true, - _ => {} - } - } - !dominated -} - fn build_game_args( - config: &InstanceConfig, - minecraft_dir: &Path, - meta_dir: &Path, - asset_index_id: &str, - auth: GameAuth, + profile: &crate::launch_profile::model::LaunchProfile, + rule_ctx: &crate::launch_profile::rules::RuleContext<'_>, + template_ctx: &crate::launch_profile::templates::TemplateContext<'_>, loader_game_args: Vec, -) -> Vec { - let mut game_args = vec![ - "--username".to_string(), - auth.username, - "--version".to_string(), - config.game_version.clone(), - "--gameDir".to_string(), - minecraft_dir.to_string_lossy().into_owned(), - "--assetsDir".to_string(), - meta_dir.join("assets").to_string_lossy().into_owned(), - "--assetIndex".to_string(), - asset_index_id.to_string(), - "--uuid".to_string(), - auth.uuid, - "--accessToken".to_string(), - auth.token, - "--userProperties".to_string(), - "{}".to_string(), - "--userType".to_string(), - auth.user_type, - ]; - game_args.extend(loader_game_args); - game_args +) -> Result<(Vec, Vec), LaunchError> { + let rendered = crate::launch_profile::render::render_args(profile, rule_ctx, template_ctx) + .map_err(|e| LaunchError::Parse(format!("Failed to render args: {e}")))?; + let mut game = rendered.game; + game.extend(loader_game_args); + Ok((rendered.jvm, game)) +} + +// existing installs from rmcl <= 0.3.0 have meta.json files in the +// stripped legacy format (no `arguments`, no `minecraftArguments`). every +// real upstream profile has at least one of those fields. on detecting the +// stripped format, re-fetch the version metadata from mojang's manifest +// and overwrite the file with the raw upstream bytes. +async fn migrate_legacy_meta_if_needed( + meta_path: &Path, + profile: &crate::launch_profile::model::LaunchProfile, + game_version: &str, +) -> Result, LaunchError> { + if profile.arguments.is_some() || profile.minecraft_arguments.is_some() { + return Ok(None); + } + + tracing::warn!( + "Cached meta.json for {game_version} is missing arguments; re-fetching from Mojang" + ); + + let client = crate::net::HttpClient::new(); + let manifest = crate::net::mojang::fetch_version_manifest(&client) + .await + .map_err(|e| LaunchError::Parse(format!("Failed to fetch version manifest: {e}")))?; + + let entry = manifest + .versions + .iter() + .find(|v| v.id == game_version) + .ok_or_else(|| { + LaunchError::Parse(format!( + "Version {game_version} not found in Mojang manifest" + )) + })?; + + let (_meta, raw) = crate::net::mojang::fetch_version_meta_with_raw(&client, entry) + .await + .map_err(|e| LaunchError::Parse(format!("Failed to re-fetch version meta: {e}")))?; + + tokio::fs::write(meta_path, &raw).await?; + + let refreshed: crate::launch_profile::model::LaunchProfile = serde_json::from_slice(&raw) + .map_err(|e| LaunchError::Parse(format!("Failed to parse refreshed meta: {e}")))?; + Ok(Some(refreshed)) } pub async fn launch( @@ -170,18 +118,50 @@ pub async fn launch( if !meta_path.exists() { return Err(LaunchError::MetaNotFound(meta_path.display().to_string())); } - let meta: MetaJson = serde_json::from_slice(&tokio::fs::read(&meta_path).await?)?; + let meta: crate::launch_profile::model::LaunchProfile = + serde_json::from_slice(&tokio::fs::read(&meta_path).await?)?; + let meta = match migrate_legacy_meta_if_needed(&meta_path, &meta, &config.game_version).await? { + Some(refreshed) => refreshed, + None => meta, + }; + + let current_features = crate::launch_profile::rules::FeatureSet::default(); + let rule_ctx = crate::launch_profile::rules::RuleContext { + os_name: match std::env::consts::OS { + "macos" => "osx", + other => other, + }, + arch: match std::env::consts::ARCH { + "x86" => "x86", + "x86_64" => "x86_64", + "aarch64" => "arm64", + other => other, + }, + features: ¤t_features, + }; + + let vanilla_main_class = meta + .main_class + .clone() + .ok_or_else(|| LaunchError::Parse("meta.json missing mainClass".into()))?; + let asset_index_id = meta + .asset_index + .as_ref() + .map(|ai| ai.id.clone()) + .unwrap_or_default(); let lib_dir = meta_dir.join("libraries"); let mut classpath: Vec = meta .libraries .iter() - .filter(|l| lib_allowed(l)) + .filter(|l| match &l.rules { + Some(rules) => crate::launch_profile::rules::evaluate(rules, &rule_ctx), + None => true, + }) .filter_map(|l| { l.downloads - .as_ref()? - .artifact .as_ref() + .and_then(|d| d.artifact.as_ref()) .map(|a| lib_dir.join(&a.path)) }) .collect(); @@ -230,7 +210,7 @@ pub async fn launch( } (profile.main_class, profile.game_arguments) } else { - (meta.main_class.clone(), Vec::new()) + (vanilla_main_class.clone(), Vec::new()) }; classpath.push( @@ -269,13 +249,6 @@ pub async fn launch( }) .unwrap_or_else(crate::net::detect_java_path); - let mut jvm: Vec = vec![ - format!("-Xms{}", config.memory_min.as_deref().unwrap_or("512M")), - format!("-Xmx{}", config.memory_max.as_deref().unwrap_or("2G")), - ]; - jvm.extend(patch_jvm_args); - jvm.extend(config.jvm_args.clone()); - // resolve auth credentials, refreshing the microsoft token if needed. let mut account_store = crate::auth::AccountStore::load(); let (mc_username, mc_uuid, mc_token, mc_user_type) = match account_store @@ -330,19 +303,41 @@ pub async fn launch( None => return Err(LaunchError::Auth("No account selected".to_owned())), }; - let game_args = build_game_args( - config, - &minecraft_dir, - meta_dir, - &meta.asset_index.id, - GameAuth { - username: mc_username, - uuid: mc_uuid, - token: mc_token, - user_type: mc_user_type, - }, - loader_game_args, - ); + let assets_root = meta_dir.join("assets"); + let natives_dir = meta_dir + .join("versions") + .join(&config.game_version) + .join("natives"); + let template_ctx = crate::launch_profile::templates::TemplateContext { + library_directory: &lib_dir, + classpath_separator: sep, + version_name: &config.game_version, + natives_directory: &natives_dir, + classpath: &cp_str, + game_directory: &minecraft_dir, + assets_root: &assets_root, + assets_index_name: &asset_index_id, + auth_player_name: &mc_username, + auth_uuid: &mc_uuid, + auth_access_token: &mc_token, + auth_xuid: "0", + user_type: &mc_user_type, + user_properties: "{}", + launcher_name: "rmcl", + launcher_version: env!("CARGO_PKG_VERSION"), + clientid: "0", + }; + + let (upstream_jvm_args, game_args) = + build_game_args(&meta, &rule_ctx, &template_ctx, loader_game_args)?; + + let mut jvm: Vec = vec![ + format!("-Xms{}", config.memory_min.as_deref().unwrap_or("512M")), + format!("-Xmx{}", config.memory_max.as_deref().unwrap_or("2G")), + ]; + jvm.extend(patch_jvm_args); + jvm.extend(upstream_jvm_args); + jvm.extend(config.jvm_args.clone()); let (kill_tx, kill_rx) = tokio::sync::oneshot::channel::<()>(); crate::running::register_kill(&name, kill_tx); @@ -478,23 +473,6 @@ pub async fn launch( mod tests { use super::*; use crate::auth::{Account, AccountType}; - use chrono::Utc; - - fn test_config() -> InstanceConfig { - InstanceConfig { - name: "test".to_owned(), - game_version: "1.7.10".to_owned(), - loader: ModLoader::Forge, - loader_version: Some("10.13.4.1614".to_owned()), - created: Utc::now(), - last_played: None, - java_path: None, - memory_max: None, - memory_min: None, - jvm_args: Vec::new(), - resolution: None, - } - } fn test_account(account_type: AccountType) -> Account { Account { @@ -509,32 +487,78 @@ mod tests { } #[test] - fn game_args_include_empty_user_properties() { - let args = build_game_args( - &test_config(), - Path::new("/instances/test/.minecraft"), - Path::new("/meta"), - "legacy", - GameAuth { - username: "TestPlayer".to_owned(), - uuid: "00000000-0000-0000-0000-000000000000".to_owned(), - token: "token".to_owned(), - user_type: "msa".to_owned(), - }, - vec![ - "--tweakClass".to_owned(), - "cpw.mods.fml.common.launcher.FMLTweaker".to_owned(), - ], - ); + fn build_game_args_renders_upstream_arguments_and_appends_loader_args() { + use crate::launch_profile::model::{Argument, Arguments, LaunchProfile}; + use crate::launch_profile::rules::{FeatureSet, RuleContext}; + use crate::launch_profile::templates::TemplateContext; + use std::path::PathBuf; + + let lib = PathBuf::from("/m/libraries"); + let nat = PathBuf::from("/m/natives"); + let game_dir = PathBuf::from("/i/.minecraft"); + let assets = PathBuf::from("/m/assets"); + + let template_ctx = TemplateContext { + library_directory: &lib, + classpath_separator: ":", + version_name: "1.20.1", + natives_directory: &nat, + classpath: "a.jar:b.jar", + game_directory: &game_dir, + assets_root: &assets, + assets_index_name: "5", + auth_player_name: "Player", + auth_uuid: "00000000-0000-0000-0000-000000000000", + auth_access_token: "token", + auth_xuid: "0", + user_type: "msa", + user_properties: "{}", + launcher_name: "rmcl", + launcher_version: "test", + clientid: "0", + }; + let features = FeatureSet::default(); + let rule_ctx = RuleContext { + os_name: "linux", + arch: "x86_64", + features: &features, + }; - let position = args - .iter() - .position(|arg| arg == "--userProperties") - .expect("game args should include --userProperties"); - assert_eq!(args.get(position + 1).map(String::as_str), Some("{}")); - assert!( - args.windows(2) - .any(|pair| pair == ["--tweakClass", "cpw.mods.fml.common.launcher.FMLTweaker"]) + let profile = LaunchProfile { + id: "1.20.1".into(), + inherits_from: None, + main_class: Some("net.minecraft.client.main.Main".into()), + libraries: Vec::new(), + arguments: Some(Arguments { + game: vec![ + Argument::Literal("--username".into()), + Argument::Literal("${auth_player_name}".into()), + ], + jvm: vec![Argument::Literal( + "-Djava.library.path=${natives_directory}".into(), + )], + }), + minecraft_arguments: None, + asset_index: None, + assets: None, + java_version: None, + downloads: None, + release_time: None, + time: None, + type_: None, + }; + + let (jvm, game_args) = build_game_args( + &profile, + &rule_ctx, + &template_ctx, + vec!["--tweakClass".into(), "foo".into()], + ) + .unwrap(); + assert_eq!(jvm, vec!["-Djava.library.path=/m/natives"]); + assert_eq!( + game_args, + vec!["--username", "Player", "--tweakClass", "foo"] ); } @@ -558,4 +582,67 @@ mod tests { assert!(account_can_launch(false, µsoft)); } + + #[test] + fn migration_predicate_triggers_when_both_argument_fields_absent() { + use crate::launch_profile::model::LaunchProfile; + let stripped = LaunchProfile { + id: "1.20.1".into(), + inherits_from: None, + main_class: Some("net.test.Main".into()), + libraries: Vec::new(), + arguments: None, + minecraft_arguments: None, + asset_index: None, + assets: None, + java_version: None, + downloads: None, + release_time: None, + time: None, + type_: None, + }; + assert!(stripped.arguments.is_none() && stripped.minecraft_arguments.is_none()); + } + + #[test] + fn migration_predicate_skips_when_modern_arguments_present() { + use crate::launch_profile::model::{Arguments, LaunchProfile}; + let modern = LaunchProfile { + id: "1.20.1".into(), + inherits_from: None, + main_class: Some("net.test.Main".into()), + libraries: Vec::new(), + arguments: Some(Arguments::default()), + minecraft_arguments: None, + asset_index: None, + assets: None, + java_version: None, + downloads: None, + release_time: None, + time: None, + type_: None, + }; + assert!(modern.arguments.is_some()); + } + + #[test] + fn migration_predicate_skips_when_legacy_minecraft_arguments_present() { + use crate::launch_profile::model::LaunchProfile; + let legacy = LaunchProfile { + id: "1.7.10".into(), + inherits_from: None, + main_class: Some("net.test.Main".into()), + libraries: Vec::new(), + arguments: None, + minecraft_arguments: Some("--username Player".into()), + asset_index: None, + assets: None, + java_version: None, + downloads: None, + release_time: None, + time: None, + type_: None, + }; + assert!(legacy.minecraft_arguments.is_some()); + } } diff --git a/src/instance/manager.rs b/src/instance/manager.rs index 87bddf6..41ffecd 100644 --- a/src/instance/manager.rs +++ b/src/instance/manager.rs @@ -115,8 +115,8 @@ impl InstanceManager { } }; - let version_meta = - crate::net::mojang::fetch_version_meta(&self.client, version_entry).await?; + let (version_meta, raw_meta_bytes) = + crate::net::mojang::fetch_version_meta_with_raw(&self.client, version_entry).await?; crate::net::mojang::download_client_jar(&self.client, &version_meta, &self.meta_dir) .await?; @@ -126,15 +126,13 @@ impl InstanceManager { .join("versions") .join(game_version) .join("meta.json"); - match serde_json::to_string_pretty(&version_meta) { - Ok(json) => { - if let Err(e) = std::fs::write(&meta_json_path, &json) { - tracing::warn!("Failed to save version meta: {}", e); - } - } - Err(e) => { - tracing::warn!("Failed to serialize version meta: {}", e); - } + if let Some(parent) = meta_json_path.parent() + && let Err(e) = std::fs::create_dir_all(parent) + { + tracing::warn!("Failed to ensure meta dir exists: {}", e); + } + if let Err(e) = std::fs::write(&meta_json_path, &raw_meta_bytes) { + tracing::warn!("Failed to save version meta: {}", e); } crate::net::mojang::download_libraries(&self.client, &version_meta, &self.meta_dir).await?; diff --git a/src/launch_profile/mod.rs b/src/launch_profile/mod.rs index 0538de4..76d13e8 100644 --- a/src/launch_profile/mod.rs +++ b/src/launch_profile/mod.rs @@ -2,9 +2,10 @@ // launch profiles. consumed by the vanilla launcher, forge/neoforge, // fabric/quilt - anything that reads a mojang-style version JSON. // -// phase 1 is pure additions: no consumers yet. later phases will wire -// these primitives into the launch pipeline and the installer paths. +// phase 2 adds the render layer that turns a parsed profile + rule context + +// template context into a flat argv list. pub mod model; +pub mod render; pub mod rules; pub mod templates; diff --git a/src/launch_profile/render.rs b/src/launch_profile/render.rs new file mode 100644 index 0000000..d1b2a48 --- /dev/null +++ b/src/launch_profile/render.rs @@ -0,0 +1,318 @@ +// renders a parsed launch profile into final argv-style lists for the JVM +// and the game. resolves conditional argument shapes (`{rules, value}`), +// filters them through the rule evaluator, and substitutes mojang template +// variables. legacy `minecraftArguments` strings are tokenised on whitespace +// and treated as a list of game args. pure function; no I/O. + +use super::model::{Argument, ArgumentValue, LaunchProfile}; +use super::rules::{RuleContext, evaluate}; +use super::templates::{TemplateContext, substitute}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RenderedArgs { + pub jvm: Vec, + pub main_class: String, + pub game: Vec, +} + +#[derive(Debug, thiserror::Error)] +pub enum RenderError { + #[error("launch profile is missing a main class")] + MissingMainClass, +} + +pub fn render_args( + profile: &LaunchProfile, + rule_ctx: &RuleContext, + template_ctx: &TemplateContext, +) -> Result { + let main_class = profile + .main_class + .clone() + .ok_or(RenderError::MissingMainClass)?; + + let mut jvm = Vec::new(); + let mut game = Vec::new(); + + if let Some(args) = &profile.arguments { + for arg in &args.jvm { + push_argument(arg, rule_ctx, template_ctx, &mut jvm); + } + for arg in &args.game { + push_argument(arg, rule_ctx, template_ctx, &mut game); + } + } else if let Some(legacy) = &profile.minecraft_arguments { + for token in legacy.split_whitespace() { + game.push(substitute(token, template_ctx)); + } + } + + Ok(RenderedArgs { + jvm, + main_class, + game, + }) +} + +fn push_argument( + arg: &Argument, + rule_ctx: &RuleContext, + template_ctx: &TemplateContext, + out: &mut Vec, +) { + match arg { + Argument::Literal(s) => out.push(substitute(s, template_ctx)), + Argument::Conditional { rules, value } => { + if !evaluate(rules, rule_ctx) { + return; + } + match value { + ArgumentValue::Single(s) => out.push(substitute(s, template_ctx)), + ArgumentValue::Multiple(items) => { + for s in items { + out.push(substitute(s, template_ctx)); + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::launch_profile::model::Arguments; + use crate::launch_profile::rules::{FeatureSet, OsCondition, Rule, RuleAction}; + use std::path::PathBuf; + + fn template_fixture<'a>( + library_directory: &'a std::path::Path, + natives_directory: &'a std::path::Path, + game_directory: &'a std::path::Path, + assets_root: &'a std::path::Path, + ) -> TemplateContext<'a> { + TemplateContext { + library_directory, + classpath_separator: ":", + version_name: "1.20.1", + natives_directory, + classpath: "a.jar:b.jar", + game_directory, + assets_root, + assets_index_name: "5", + auth_player_name: "Player", + auth_uuid: "00000000-0000-0000-0000-000000000000", + auth_access_token: "token", + auth_xuid: "0", + user_type: "msa", + user_properties: "{}", + launcher_name: "rmcl", + launcher_version: "0.3.0", + clientid: "0", + } + } + + fn minimal_profile() -> LaunchProfile { + LaunchProfile { + id: "test".into(), + inherits_from: None, + main_class: Some("net.test.Main".into()), + libraries: Vec::new(), + arguments: None, + minecraft_arguments: None, + asset_index: None, + assets: None, + java_version: None, + downloads: None, + release_time: None, + time: None, + type_: None, + } + } + + #[test] + fn legacy_minecraft_arguments_render_into_game() { + let lib = PathBuf::from("/m/libraries"); + let nat = PathBuf::from("/m/natives"); + let game = PathBuf::from("/i/.minecraft"); + let assets = PathBuf::from("/m/assets"); + let template_ctx = template_fixture(&lib, &nat, &game, &assets); + let features = FeatureSet::default(); + let rule_ctx = RuleContext { + os_name: "linux", + arch: "x86_64", + features: &features, + }; + + let mut profile = minimal_profile(); + profile.minecraft_arguments = + Some("--username ${auth_player_name} --version ${version_name}".into()); + + let rendered = render_args(&profile, &rule_ctx, &template_ctx).unwrap(); + assert_eq!(rendered.main_class, "net.test.Main"); + assert!(rendered.jvm.is_empty()); + assert_eq!( + rendered.game, + vec!["--username", "Player", "--version", "1.20.1"] + ); + } + + #[test] + fn modern_arguments_render_with_literals_and_substitutions() { + let lib = PathBuf::from("/m/libraries"); + let nat = PathBuf::from("/m/natives"); + let game = PathBuf::from("/i/.minecraft"); + let assets = PathBuf::from("/m/assets"); + let template_ctx = template_fixture(&lib, &nat, &game, &assets); + let features = FeatureSet::default(); + let rule_ctx = RuleContext { + os_name: "linux", + arch: "x86_64", + features: &features, + }; + + let mut profile = minimal_profile(); + profile.arguments = Some(Arguments { + game: vec![ + Argument::Literal("--username".into()), + Argument::Literal("${auth_player_name}".into()), + ], + jvm: vec![Argument::Literal( + "-Djava.library.path=${natives_directory}".into(), + )], + }); + + let rendered = render_args(&profile, &rule_ctx, &template_ctx).unwrap(); + assert_eq!(rendered.game, vec!["--username", "Player"]); + assert_eq!(rendered.jvm, vec!["-Djava.library.path=/m/natives"]); + } + + #[test] + fn conditional_argument_with_single_value_is_filtered_by_os_rule() { + let lib = PathBuf::from("/m/libraries"); + let nat = PathBuf::from("/m/natives"); + let game = PathBuf::from("/i/.minecraft"); + let assets = PathBuf::from("/m/assets"); + let template_ctx = template_fixture(&lib, &nat, &game, &assets); + let features = FeatureSet::default(); + let rule_ctx = RuleContext { + os_name: "linux", + arch: "x86_64", + features: &features, + }; + + let osx_only = Argument::Conditional { + rules: vec![Rule { + action: RuleAction::Allow, + os: Some(OsCondition { + name: Some("osx".into()), + arch: None, + }), + features: None, + }], + value: ArgumentValue::Single("-XstartOnFirstThread".into()), + }; + + let mut profile = minimal_profile(); + profile.arguments = Some(Arguments { + game: Vec::new(), + jvm: vec![osx_only], + }); + + let rendered = render_args(&profile, &rule_ctx, &template_ctx).unwrap(); + assert!( + rendered.jvm.is_empty(), + "osx-only arg should be skipped on linux" + ); + } + + #[test] + fn conditional_argument_with_multiple_value_pushes_all() { + let lib = PathBuf::from("/m/libraries"); + let nat = PathBuf::from("/m/natives"); + let game = PathBuf::from("/i/.minecraft"); + let assets = PathBuf::from("/m/assets"); + let template_ctx = template_fixture(&lib, &nat, &game, &assets); + let features = FeatureSet::default(); + let rule_ctx = RuleContext { + os_name: "linux", + arch: "x86_64", + features: &features, + }; + + let linux_arg = Argument::Conditional { + rules: vec![Rule { + action: RuleAction::Allow, + os: Some(OsCondition { + name: Some("linux".into()), + arch: None, + }), + features: None, + }], + value: ArgumentValue::Multiple(vec![ + "--add-opens".into(), + "java.base/sun.security.util=ALL-UNNAMED".into(), + ]), + }; + + let mut profile = minimal_profile(); + profile.arguments = Some(Arguments { + game: Vec::new(), + jvm: vec![linux_arg], + }); + + let rendered = render_args(&profile, &rule_ctx, &template_ctx).unwrap(); + assert_eq!( + rendered.jvm, + vec!["--add-opens", "java.base/sun.security.util=ALL-UNNAMED"] + ); + } + + #[test] + fn missing_main_class_returns_error() { + let lib = PathBuf::from("/m/libraries"); + let nat = PathBuf::from("/m/natives"); + let game = PathBuf::from("/i/.minecraft"); + let assets = PathBuf::from("/m/assets"); + let template_ctx = template_fixture(&lib, &nat, &game, &assets); + let features = FeatureSet::default(); + let rule_ctx = RuleContext { + os_name: "linux", + arch: "x86_64", + features: &features, + }; + + let mut profile = minimal_profile(); + profile.main_class = None; + + let result = render_args(&profile, &rule_ctx, &template_ctx); + assert!(matches!(result, Err(RenderError::MissingMainClass))); + } + + #[test] + fn modern_arguments_takes_precedence_over_legacy_field() { + // a profile that somehow has both arguments and minecraft_arguments + // should use arguments only (legacy is fallback). + let lib = PathBuf::from("/m/libraries"); + let nat = PathBuf::from("/m/natives"); + let game = PathBuf::from("/i/.minecraft"); + let assets = PathBuf::from("/m/assets"); + let template_ctx = template_fixture(&lib, &nat, &game, &assets); + let features = FeatureSet::default(); + let rule_ctx = RuleContext { + os_name: "linux", + arch: "x86_64", + features: &features, + }; + + let mut profile = minimal_profile(); + profile.arguments = Some(Arguments { + game: vec![Argument::Literal("--from-arguments".into())], + jvm: Vec::new(), + }); + profile.minecraft_arguments = Some("--from-legacy".into()); + + let rendered = render_args(&profile, &rule_ctx, &template_ctx).unwrap(); + assert_eq!(rendered.game, vec!["--from-arguments"]); + } +} diff --git a/src/net/mod.rs b/src/net/mod.rs index 65da353..c5edf00 100644 --- a/src/net/mod.rs +++ b/src/net/mod.rs @@ -71,6 +71,10 @@ impl HttpClient { pub async fn get_json(&self, url: &str) -> Result { Ok(self.get(url).await?.json().await?) } + + pub async fn get_bytes(&self, url: &str) -> Result, NetError> { + Ok(self.get(url).await?.bytes().await?.to_vec()) + } } const MAX_RETRIES: u32 = 3; diff --git a/src/net/mojang.rs b/src/net/mojang.rs index 1a9b2f8..b79c154 100644 --- a/src/net/mojang.rs +++ b/src/net/mojang.rs @@ -76,7 +76,7 @@ pub struct Download { pub struct Library { pub name: String, pub downloads: LibraryDownloads, - pub rules: Option>, + pub rules: Option>, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -92,17 +92,6 @@ pub struct Artifact { pub size: u64, } -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct Rule { - pub action: String, - pub os: Option, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct OsRule { - pub name: Option, -} - #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct JavaVersion { @@ -131,6 +120,20 @@ pub async fn fetch_version_meta( client.get_json(&entry.url).await } +// like fetch_version_meta but also returns the raw response bytes so the +// caller can write the upstream JSON byte-for-byte to disk. used by the +// install path so we don't lose data (e.g. arguments.jvm) by re-serializing +// through our narrow VersionMeta struct. +pub async fn fetch_version_meta_with_raw( + client: &HttpClient, + entry: &VersionEntry, +) -> Result<(VersionMeta, Vec), NetError> { + let raw = client.get_bytes(&entry.url).await?; + let parsed: VersionMeta = serde_json::from_slice(&raw) + .map_err(|e| NetError::Parse(format!("Failed to parse version meta: {e}")))?; + Ok((parsed, raw)) +} + pub async fn download_client_jar( client: &HttpClient, meta: &VersionMeta, @@ -169,9 +172,18 @@ pub async fn download_libraries( ) -> Result<(), NetError> { set_action("Downloading libraries..."); + let features = crate::launch_profile::rules::FeatureSet::default(); + let rule_ctx = crate::launch_profile::rules::RuleContext { + os_name: mojang_os_name(), + arch: mojang_arch_name(), + features: &features, + }; + let mut downloads = Vec::new(); for library in &meta.libraries { - if !library_allowed_for_current_os(library) { + if let Some(rules) = &library.rules + && !crate::launch_profile::rules::evaluate(rules, &rule_ctx) + { continue; } @@ -281,43 +293,6 @@ pub async fn download_assets( result } -// mojang's rule system is a bit quirky: no rules means "allow everywhere", -// and rules are evaluated in order where the last matching rule wins. -// a "disallow" for the current OS is an immediate reject though. -fn library_allowed_for_current_os(library: &Library) -> bool { - let rules = match &library.rules { - Some(rules) => rules, - None => return true, - }; - - let current_os = mojang_os_name(); - let mut allowed = false; - - for rule in rules { - let matches_os = match &rule.os { - Some(os_rule) => match &os_rule.name { - Some(name) => name == current_os, - None => true, - }, - None => true, - }; - - if !matches_os { - continue; - } - - if rule.action == "disallow" { - return false; - } - - if rule.action == "allow" { - allowed = true; - } - } - - allowed -} - // mojang calls macOS "osx" because apparently it's still 2012 fn mojang_os_name() -> &'static str { match std::env::consts::OS { @@ -326,6 +301,15 @@ fn mojang_os_name() -> &'static str { } } +fn mojang_arch_name() -> &'static str { + match std::env::consts::ARCH { + "x86" => "x86", + "x86_64" => "x86_64", + "aarch64" => "arm64", + other => other, + } +} + // bounded parallel downloader. spawns up to MAX_CONCURRENT_DOWNLOADS tasks // and feeds new ones in as each completes. collects errors but keeps going // so it downloads as much as possible before reporting the first failure. From 63014726e92cf9090298d81e1ce6b638caf382b1 Mon Sep 17 00:00:00 2001 From: objz Date: Mon, 25 May 2026 15:23:09 +0200 Subject: [PATCH 04/12] added: inheritsFrom resolution with merge_into and chain walker new launch_profile::resolve module walks a profile's inheritsFrom chain, loading parents from meta_dir/versions//meta.json and merging them into a single flat LaunchProfile per mojang's documented semantics. pure merge_into handles field-level merging; async resolve walks the chain with HashSet-based cycle detection and an 8-level depth cap. --- src/launch_profile/mod.rs | 4 +- src/launch_profile/resolve.rs | 505 ++++++++++++++++++++++++++++++++++ 2 files changed, 507 insertions(+), 2 deletions(-) create mode 100644 src/launch_profile/resolve.rs diff --git a/src/launch_profile/mod.rs b/src/launch_profile/mod.rs index 76d13e8..4d133da 100644 --- a/src/launch_profile/mod.rs +++ b/src/launch_profile/mod.rs @@ -2,10 +2,10 @@ // launch profiles. consumed by the vanilla launcher, forge/neoforge, // fabric/quilt - anything that reads a mojang-style version JSON. // -// phase 2 adds the render layer that turns a parsed profile + rule context + -// template context into a flat argv list. +// phase 3 adds the `resolve` module that walks `inheritsFrom` chains. pub mod model; pub mod render; +pub mod resolve; pub mod rules; pub mod templates; diff --git a/src/launch_profile/resolve.rs b/src/launch_profile/resolve.rs new file mode 100644 index 0000000..dd37a78 --- /dev/null +++ b/src/launch_profile/resolve.rs @@ -0,0 +1,505 @@ +// resolves the `inheritsFrom` chain of a parsed `LaunchProfile`. mojang's +// version JSON allows a profile to inherit from another profile by id; the +// loaders (forge / neoforge / fabric / quilt) use this to layer their +// additions on top of a vanilla base. this module walks the chain and +// returns a single flat profile. +// +// merge semantics (per the mojang launcher and the major third-party +// launchers that interoperate with it): +// - scalar fields: child wins if Some, else parent. +// - libraries and arguments: parent ++ child (parent first). child +// entries are appended after parent's. +// - merge_into preserves parent's inherits_from so resolve() can keep +// walking; resolve() clears the final result's inherits_from after +// the loop exits. +// +// pure function `merge_into` handles the field-by-field merge math. +// async `resolve` does the chain walking with cycle detection and a depth +// cap. tests cover both layers independently. + +use std::path::Path; + +use super::model::{Arguments, LaunchProfile}; + +const MAX_INHERITANCE_DEPTH: usize = 8; + +#[derive(Debug, thiserror::Error)] +pub enum ResolveError { + #[error("parent profile not found at {0}")] + ParentNotFound(String), + #[error("failed to parse parent profile {0}: {1}")] + ParseError(String, String), + #[error("circular inheritance detected: {0} appears more than once in the chain")] + CircularInheritance(String), + #[error("inheritance chain exceeded {0} levels")] + DepthExceeded(usize), + #[error("I/O error reading parent profile: {0}")] + Io(#[from] std::io::Error), +} + +// merges `child` on top of `parent`. child takes precedence for scalar +// fields. for libraries and arguments, child entries are appended after +// parent's. `id` is taken from child. `inherits_from` is taken from +// parent (so resolve() can keep walking the chain - resolve() clears it +// to None after the final iteration). +pub fn merge_into(child: LaunchProfile, parent: LaunchProfile) -> LaunchProfile { + LaunchProfile { + id: child.id, + inherits_from: parent.inherits_from, + main_class: child.main_class.or(parent.main_class), + libraries: { + let mut libs = parent.libraries; + libs.extend(child.libraries); + libs + }, + arguments: merge_arguments(child.arguments, parent.arguments), + minecraft_arguments: child.minecraft_arguments.or(parent.minecraft_arguments), + asset_index: child.asset_index.or(parent.asset_index), + assets: child.assets.or(parent.assets), + java_version: child.java_version.or(parent.java_version), + downloads: child.downloads.or(parent.downloads), + release_time: child.release_time.or(parent.release_time), + time: child.time.or(parent.time), + type_: child.type_.or(parent.type_), + } +} + +fn merge_arguments(child: Option, parent: Option) -> Option { + match (child, parent) { + (None, None) => None, + (Some(c), None) => Some(c), + (None, Some(p)) => Some(p), + (Some(c), Some(p)) => { + let mut game = p.game; + game.extend(c.game); + let mut jvm = p.jvm; + jvm.extend(c.jvm); + Some(Arguments { game, jvm }) + } + } +} + +pub async fn resolve( + profile: LaunchProfile, + meta_dir: &Path, +) -> Result { + use std::collections::HashSet; + + let mut visited: HashSet = HashSet::new(); + visited.insert(profile.id.clone()); + + let mut current = profile; + let mut depth = 0; + + while let Some(parent_id) = current.inherits_from.clone() { + depth += 1; + if depth > MAX_INHERITANCE_DEPTH { + return Err(ResolveError::DepthExceeded(MAX_INHERITANCE_DEPTH)); + } + if !visited.insert(parent_id.clone()) { + return Err(ResolveError::CircularInheritance(parent_id)); + } + + let parent_path = meta_dir.join("versions").join(&parent_id).join("meta.json"); + if !parent_path.exists() { + return Err(ResolveError::ParentNotFound( + parent_path.display().to_string(), + )); + } + let parent_bytes = tokio::fs::read(&parent_path).await?; + let parent: LaunchProfile = serde_json::from_slice(&parent_bytes) + .map_err(|e| ResolveError::ParseError(parent_id.clone(), e.to_string()))?; + + current = merge_into(current, parent); + } + + current.inherits_from = None; + Ok(current) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::launch_profile::model::{Argument, ArgumentValue, AssetIndex, JavaVersion, Library}; + use crate::launch_profile::rules::{Rule, RuleAction}; + + fn empty_profile(id: &str) -> LaunchProfile { + LaunchProfile { + id: id.into(), + inherits_from: None, + main_class: None, + libraries: Vec::new(), + arguments: None, + minecraft_arguments: None, + asset_index: None, + assets: None, + java_version: None, + downloads: None, + release_time: None, + time: None, + type_: None, + } + } + + fn lib(name: &str) -> Library { + Library { + name: name.into(), + downloads: None, + rules: None, + url: None, + } + } + + fn allow_linux_rule() -> Rule { + Rule { + action: RuleAction::Allow, + os: Some(crate::launch_profile::rules::OsCondition { + name: Some("linux".into()), + arch: None, + }), + features: None, + } + } + + #[test] + fn child_id_wins() { + let mut child = empty_profile("child"); + let parent = empty_profile("parent"); + child.main_class = None; + let merged = merge_into(child, parent); + assert_eq!(merged.id, "child"); + } + + #[test] + fn merge_carries_parent_inherits_from() { + // merge_into preserves parent's inherits_from so resolve() can keep + // walking. resolve() itself clears the final result's inherits_from + // after the loop exits. + let mut child = empty_profile("child"); + child.inherits_from = Some("parent".into()); + let mut parent = empty_profile("parent"); + parent.inherits_from = Some("grandparent".into()); + let merged = merge_into(child, parent); + assert_eq!(merged.inherits_from.as_deref(), Some("grandparent")); + } + + #[test] + fn merge_with_root_parent_clears_inherits_from() { + // parent with no inherits_from means the chain ends. + let mut child = empty_profile("child"); + child.inherits_from = Some("parent".into()); + let parent = empty_profile("parent"); + let merged = merge_into(child, parent); + assert!(merged.inherits_from.is_none()); + } + + #[test] + fn child_main_class_overrides_parent() { + let mut child = empty_profile("child"); + let mut parent = empty_profile("parent"); + child.main_class = Some("child.Main".into()); + parent.main_class = Some("parent.Main".into()); + let merged = merge_into(child, parent); + assert_eq!(merged.main_class.as_deref(), Some("child.Main")); + } + + #[test] + fn parent_main_class_used_when_child_missing() { + let child = empty_profile("child"); + let mut parent = empty_profile("parent"); + parent.main_class = Some("parent.Main".into()); + let merged = merge_into(child, parent); + assert_eq!(merged.main_class.as_deref(), Some("parent.Main")); + } + + #[test] + fn libraries_are_concatenated_parent_first() { + let mut child = empty_profile("child"); + let mut parent = empty_profile("parent"); + parent.libraries = vec![lib("p1"), lib("p2")]; + child.libraries = vec![lib("c1")]; + let merged = merge_into(child, parent); + let names: Vec<_> = merged.libraries.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, vec!["p1", "p2", "c1"]); + } + + #[test] + fn arguments_are_concatenated_parent_first() { + let mut child = empty_profile("child"); + let mut parent = empty_profile("parent"); + parent.arguments = Some(Arguments { + game: vec![Argument::Literal("--from-parent-game".into())], + jvm: vec![Argument::Literal("--from-parent-jvm".into())], + }); + child.arguments = Some(Arguments { + game: vec![Argument::Literal("--from-child-game".into())], + jvm: vec![Argument::Literal("--from-child-jvm".into())], + }); + let merged = merge_into(child, parent); + let args = merged.arguments.expect("arguments present"); + assert_eq!( + args.game, + vec![ + Argument::Literal("--from-parent-game".into()), + Argument::Literal("--from-child-game".into()), + ] + ); + assert_eq!( + args.jvm, + vec![ + Argument::Literal("--from-parent-jvm".into()), + Argument::Literal("--from-child-jvm".into()), + ] + ); + } + + #[test] + fn arguments_from_child_only_carry_through() { + let mut child = empty_profile("child"); + let parent = empty_profile("parent"); + child.arguments = Some(Arguments { + game: vec![Argument::Literal("--child".into())], + jvm: Vec::new(), + }); + let merged = merge_into(child, parent); + let args = merged.arguments.expect("arguments present"); + assert_eq!(args.game.len(), 1); + assert!(args.jvm.is_empty()); + } + + #[test] + fn arguments_from_parent_only_carry_through() { + let child = empty_profile("child"); + let mut parent = empty_profile("parent"); + parent.arguments = Some(Arguments { + game: Vec::new(), + jvm: vec![Argument::Literal("--parent-jvm".into())], + }); + let merged = merge_into(child, parent); + let args = merged.arguments.expect("arguments present"); + assert!(args.game.is_empty()); + assert_eq!(args.jvm.len(), 1); + } + + #[test] + fn conditional_arguments_with_rules_survive_merge() { + // make sure the Argument::Conditional shape isn't accidentally + // flattened or filtered during merging - rule eval happens later + // at render time, not during merge. + let mut child = empty_profile("child"); + let parent = empty_profile("parent"); + child.arguments = Some(Arguments { + game: vec![Argument::Conditional { + rules: vec![allow_linux_rule()], + value: ArgumentValue::Single("--linux-only".into()), + }], + jvm: Vec::new(), + }); + let merged = merge_into(child, parent); + let args = merged.arguments.expect("arguments present"); + match &args.game[0] { + Argument::Conditional { rules, .. } => { + assert_eq!(rules.len(), 1); + assert_eq!(rules[0].action, RuleAction::Allow); + } + _ => panic!("expected conditional argument to survive merge"), + } + } + + #[test] + fn legacy_minecraft_arguments_child_overrides_parent() { + let mut child = empty_profile("child"); + let mut parent = empty_profile("parent"); + child.minecraft_arguments = Some("--child".into()); + parent.minecraft_arguments = Some("--parent".into()); + let merged = merge_into(child, parent); + assert_eq!(merged.minecraft_arguments.as_deref(), Some("--child")); + } + + #[test] + fn asset_index_inherits_from_parent_when_child_absent() { + let child = empty_profile("child"); + let mut parent = empty_profile("parent"); + parent.asset_index = Some(AssetIndex { + id: "5".into(), + url: "https://example.invalid/5.json".into(), + sha1: "0".repeat(40), + size: None, + total_size: None, + }); + let merged = merge_into(child, parent); + assert!(merged.asset_index.is_some()); + assert_eq!(merged.asset_index.unwrap().id, "5"); + } + + #[test] + fn java_version_inherits_from_parent_when_child_absent() { + let child = empty_profile("child"); + let mut parent = empty_profile("parent"); + parent.java_version = Some(JavaVersion { + component: Some("java-runtime-gamma".into()), + major_version: 17, + }); + let merged = merge_into(child, parent); + assert_eq!( + merged.java_version.as_ref().map(|j| j.major_version), + Some(17) + ); + } + + use tempfile::TempDir; + + fn write_profile(meta_dir: &Path, profile: &LaunchProfile) { + let path = meta_dir + .join("versions") + .join(&profile.id) + .join("meta.json"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + let json = serde_json::to_string_pretty(profile).unwrap(); + std::fs::write(&path, json).unwrap(); + } + + #[tokio::test] + async fn resolve_returns_unchanged_when_no_inherits_from() { + let tmp = TempDir::new().unwrap(); + let profile = empty_profile("standalone"); + let resolved = resolve(profile, tmp.path()).await.unwrap(); + assert_eq!(resolved.id, "standalone"); + assert!(resolved.inherits_from.is_none()); + } + + #[tokio::test] + async fn resolve_single_level_inheritance_merges_parent() { + let tmp = TempDir::new().unwrap(); + + let mut parent = empty_profile("1.20.1"); + parent.main_class = Some("net.minecraft.client.main.Main".into()); + parent.libraries = vec![lib("vanilla-lib")]; + write_profile(tmp.path(), &parent); + + let mut child = empty_profile("1.20.1-forge-47.2.0"); + child.inherits_from = Some("1.20.1".into()); + child.libraries = vec![lib("forge-lib")]; + + let resolved = resolve(child, tmp.path()).await.unwrap(); + assert_eq!(resolved.id, "1.20.1-forge-47.2.0"); + assert!(resolved.inherits_from.is_none()); + assert_eq!( + resolved.main_class.as_deref(), + Some("net.minecraft.client.main.Main") + ); + let names: Vec<_> = resolved.libraries.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, vec!["vanilla-lib", "forge-lib"]); + } + + #[tokio::test] + async fn resolve_errors_when_parent_missing() { + let tmp = TempDir::new().unwrap(); + + let mut child = empty_profile("1.20.1-forge-47.2.0"); + child.inherits_from = Some("1.20.1".into()); + + let err = resolve(child, tmp.path()).await.unwrap_err(); + assert!( + matches!(err, ResolveError::ParentNotFound(_)), + "expected ParentNotFound, got {err:?}" + ); + } + + #[tokio::test] + async fn resolve_errors_when_parent_is_invalid_json() { + let tmp = TempDir::new().unwrap(); + + let parent_path = tmp.path().join("versions").join("1.20.1").join("meta.json"); + std::fs::create_dir_all(parent_path.parent().unwrap()).unwrap(); + std::fs::write(&parent_path, "{ not valid json").unwrap(); + + let mut child = empty_profile("1.20.1-forge-47.2.0"); + child.inherits_from = Some("1.20.1".into()); + + let err = resolve(child, tmp.path()).await.unwrap_err(); + assert!( + matches!(err, ResolveError::ParseError(_, _)), + "expected ParseError, got {err:?}" + ); + } + + #[tokio::test] + async fn resolve_multi_level_chain_merges_all_parents() { + let tmp = TempDir::new().unwrap(); + + // chain: grandchild -> child -> root (vanilla). + let mut root = empty_profile("1.20.1"); + root.main_class = Some("net.minecraft.client.main.Main".into()); + root.libraries = vec![lib("vanilla-lib")]; + write_profile(tmp.path(), &root); + + let mut child = empty_profile("1.20.1-forge-47.2.0"); + child.inherits_from = Some("1.20.1".into()); + child.libraries = vec![lib("forge-lib")]; + write_profile(tmp.path(), &child); + + let mut grandchild = empty_profile("1.20.1-forge-47.2.0-modpack"); + grandchild.inherits_from = Some("1.20.1-forge-47.2.0".into()); + grandchild.libraries = vec![lib("modpack-lib")]; + + let resolved = resolve(grandchild, tmp.path()).await.unwrap(); + assert_eq!(resolved.id, "1.20.1-forge-47.2.0-modpack"); + assert!(resolved.inherits_from.is_none()); + assert_eq!( + resolved.main_class.as_deref(), + Some("net.minecraft.client.main.Main") + ); + // libs: root ++ child ++ grandchild (each parent prepended) + let names: Vec<_> = resolved.libraries.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, vec!["vanilla-lib", "forge-lib", "modpack-lib"]); + } + + #[tokio::test] + async fn resolve_detects_circular_chain() { + let tmp = TempDir::new().unwrap(); + + // a -> b -> a (cycle) + let mut a = empty_profile("a"); + a.inherits_from = Some("b".into()); + write_profile(tmp.path(), &a); + + let mut b = empty_profile("b"); + b.inherits_from = Some("a".into()); + write_profile(tmp.path(), &b); + + // start from a fresh "a" profile that asks to inherit from b + let mut entry = empty_profile("a"); + entry.inherits_from = Some("b".into()); + + let err = resolve(entry, tmp.path()).await.unwrap_err(); + assert!( + matches!(err, ResolveError::CircularInheritance(ref s) if s == "a"), + "expected CircularInheritance(a), got {err:?}" + ); + } + + #[tokio::test] + async fn resolve_caps_depth() { + let tmp = TempDir::new().unwrap(); + + // build a chain 0 -> 1 -> 2 -> ... -> 10. with cap of 8, hitting 10 + // should fail with DepthExceeded. + for i in 0..=10 { + let mut p = empty_profile(&format!("v{i}")); + if i < 10 { + p.inherits_from = Some(format!("v{}", i + 1)); + } + write_profile(tmp.path(), &p); + } + + let mut entry = empty_profile("entry"); + entry.inherits_from = Some("v0".into()); + + let err = resolve(entry, tmp.path()).await.unwrap_err(); + assert!( + matches!(err, ResolveError::DepthExceeded(_)), + "expected DepthExceeded, got {err:?}" + ); + } +} From 0d00678a7cc17f2f52d08b616c96fff90dbc4fdf Mon Sep 17 00:00:00 2001 From: objz Date: Mon, 25 May 2026 16:08:47 +0200 Subject: [PATCH 05/12] added: modern Forge and NeoForge native pass-through (user-facing fix) stop stripping the installer's version JSON in save_installer_profile - copy it byte-for-byte so the upstream arguments.jvm (with the --add-opens flags forge 1.17+ needs on java 17+) reaches launch time. launch loads the loader profile as LaunchProfile, sets an implicit inheritsFrom = game_version when absent, resolves through vanilla, then renders args. classpath assembly unifies into one loop handling both vanilla-style and loader-style libraries. lazy migration of pre-rework loader-profiles from the installer's version JSON when still on disk. fixes the reported Forge 1.17+ on java 17+ IllegalAccessError. --- src/instance/launch/mod.rs | 298 ++++++++++++++++++++++++++++--------- src/instance/loader/mod.rs | 92 ++++++++---- 2 files changed, 291 insertions(+), 99 deletions(-) diff --git a/src/instance/launch/mod.rs b/src/instance/launch/mod.rs index a366d72..60cf0b4 100644 --- a/src/instance/launch/mod.rs +++ b/src/instance/launch/mod.rs @@ -27,20 +27,6 @@ pub enum LaunchError { Auth(String), } -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct LoaderProfileJson { - main_class: String, - libraries: Vec, - #[serde(default)] - game_arguments: Vec, -} - -#[derive(serde::Deserialize)] -struct LoaderLibrary { - name: String, -} - fn account_can_launch(has_microsoft_account: bool, account: &Account) -> bool { account.account_type == AccountType::Microsoft || has_microsoft_account } @@ -102,6 +88,87 @@ async fn migrate_legacy_meta_if_needed( Ok(Some(refreshed)) } +// the forge/neoforge installer writes its version JSON to a path that's +// loader-specific. encode the naming convention here so migration code +// can find the original file when it needs to rebuild our cache. +fn installer_version_dir_name( + loader: ModLoader, + game_version: &str, + loader_version: &str, +) -> Option { + match loader { + ModLoader::Forge => Some(format!("{game_version}-forge-{loader_version}")), + ModLoader::NeoForge => Some(format!("neoforge-{loader_version}")), + ModLoader::Vanilla | ModLoader::Fabric | ModLoader::Quilt => None, + } +} + +// loader profiles installed by rmcl <= 0.3.0 are in our stripped +// `{mainClass, libraries[, gameArguments]}` format, which silently drops +// `inheritsFrom`, `arguments.jvm`, and conditional rules from upstream. +// detect that shape (no inheritsFrom AND no arguments AND no +// minecraftArguments - every real upstream profile has at least one) and +// rebuild from the installer's original JSON if it's still on disk. +async fn migrate_legacy_loader_profile_if_needed( + profile_path: &Path, + profile: &crate::launch_profile::model::LaunchProfile, + config: &InstanceConfig, + instance_dir: &Path, +) -> Result, LaunchError> { + let is_legacy = profile.inherits_from.is_none() + && profile.arguments.is_none() + && profile.minecraft_arguments.is_none(); + if !is_legacy { + return Ok(None); + } + + let loader_version = config.loader_version.as_deref().unwrap_or("unknown"); + let Some(version_dir) = + installer_version_dir_name(config.loader, &config.game_version, loader_version) + else { + // Fabric/Quilt fall through to the same path but don't have an + // installer-written JSON to fall back on. Phase 6 will widen + // those structs to capture the full upstream JSON at install + // time; until then, the user reinstalls. + return Err(LaunchError::Parse(format!( + "Loader profile at {} is in an outdated format. Reinstall {} for this instance.", + profile_path.display(), + config.loader + ))); + }; + + let installer_json_path = instance_dir + .join(".minecraft") + .join("versions") + .join(&version_dir) + .join(format!("{version_dir}.json")); + + if !installer_json_path.exists() { + return Err(LaunchError::Parse(format!( + "Loader profile at {} is in an outdated format and the installer JSON at {} \ + is missing. Reinstall {} for this instance.", + profile_path.display(), + installer_json_path.display(), + config.loader + ))); + } + + tracing::warn!( + "Loader profile {} is in legacy format; rebuilding from {}", + profile_path.display(), + installer_json_path.display() + ); + + let raw = tokio::fs::read(&installer_json_path).await?; + tokio::fs::write(profile_path, &raw).await?; + + let refreshed: crate::launch_profile::model::LaunchProfile = serde_json::from_slice(&raw) + .map_err(|e| { + LaunchError::Parse(format!("Failed to parse refreshed loader profile: {e}")) + })?; + Ok(Some(refreshed)) +} + pub async fn launch( config: &InstanceConfig, instances_dir: &Path, @@ -140,10 +207,6 @@ pub async fn launch( features: ¤t_features, }; - let vanilla_main_class = meta - .main_class - .clone() - .ok_or_else(|| LaunchError::Parse("meta.json missing mainClass".into()))?; let asset_index_id = meta .asset_index .as_ref() @@ -151,20 +214,6 @@ pub async fn launch( .unwrap_or_default(); let lib_dir = meta_dir.join("libraries"); - let mut classpath: Vec = meta - .libraries - .iter() - .filter(|l| match &l.rules { - Some(rules) => crate::launch_profile::rules::evaluate(rules, &rule_ctx), - None => true, - }) - .filter_map(|l| { - l.downloads - .as_ref() - .and_then(|d| d.artifact.as_ref()) - .map(|a| lib_dir.join(&a.path)) - }) - .collect(); let lv = config.loader_version.as_deref().unwrap_or("unknown"); let profile_filename = match config.loader { @@ -175,43 +224,83 @@ pub async fn launch( ModLoader::NeoForge => Some(format!("neoforge-{}.json", lv)), }; - // if there's a mod loader, read its profile to get the real main class, - // extra libraries, and any additional game arguments (e.g. --tweakClass) - let (main_class, loader_game_args) = if let Some(filename) = profile_filename { - let profile_path = meta_dir.join("loader-profiles").join(&filename); - if !profile_path.exists() { - return Err(LaunchError::MetaNotFound( - profile_path.display().to_string(), - )); + // load the loader profile (if any), migrate from the old stripped format + // if needed, and resolve `inheritsFrom` against the vanilla parent (which + // the vanilla meta migration above ensured is fresh on disk). when no + // loader is configured we use the already-loaded vanilla meta directly. + let merged_profile: crate::launch_profile::model::LaunchProfile = + if let Some(filename) = &profile_filename { + let profile_path = meta_dir.join("loader-profiles").join(filename); + if !profile_path.exists() { + return Err(LaunchError::MetaNotFound( + profile_path.display().to_string(), + )); + } + let mut loader_profile: crate::launch_profile::model::LaunchProfile = + serde_json::from_slice(&tokio::fs::read(&profile_path).await?)?; + + if let Some(refreshed) = migrate_legacy_loader_profile_if_needed( + &profile_path, + &loader_profile, + config, + &instance_dir, + ) + .await? + { + loader_profile = refreshed; + } + + // legacy installer-written profiles (and any loader profile that + // omits inheritsFrom) still need to be layered over vanilla. set + // the inherit explicitly so resolve() walks the chain. + if loader_profile.inherits_from.is_none() { + loader_profile.inherits_from = Some(config.game_version.clone()); + } + + crate::launch_profile::resolve::resolve(loader_profile, meta_dir) + .await + .map_err(|e| LaunchError::Parse(format!("Failed to resolve loader profile: {e}")))? + } else { + meta.clone() + }; + + let main_class = merged_profile + .main_class + .clone() + .ok_or_else(|| LaunchError::Parse("merged profile missing mainClass".into()))?; + + // rebuild the classpath from the merged profile. vanilla-style libraries + // have `downloads.artifact.path` set and live in meta_dir/libraries/. + // loader-style libraries only have a maven coordinate; for forge/neoforge, + // the installer drops some of them into /.minecraft/libraries/ + // so we check there first. + let has_local_libs = matches!(config.loader, ModLoader::Forge | ModLoader::NeoForge); + let local_lib_dir = minecraft_dir.join("libraries"); + + let mut classpath: Vec = Vec::new(); + for lib in &merged_profile.libraries { + if let Some(rules) = &lib.rules + && !crate::launch_profile::rules::evaluate(rules, &rule_ctx) + { + continue; } - let profile: LoaderProfileJson = - serde_json::from_slice(&tokio::fs::read(&profile_path).await?)?; - - // forge/neoforge install some libs locally in the instance dir. - // local libs take priority so modpacks can ship patched versions - // (e.g. GTNH's launchwrapper patched for java 9+ compatibility) - let has_local_libs = matches!(config.loader, ModLoader::Forge | ModLoader::NeoForge); - let local_lib_dir = minecraft_dir.join("libraries"); - - for lib in &profile.libraries { - if let Some(p) = crate::net::maven_coord_to_path(&lib.name) { - if has_local_libs { - let in_local = local_lib_dir.join(&p); - let in_meta = lib_dir.join(&p); - if in_local.exists() { - classpath.push(in_local); - } else if in_meta.exists() { - classpath.push(in_meta); - } - } else { - classpath.push(lib_dir.join(p)); + + if let Some(artifact) = lib.downloads.as_ref().and_then(|d| d.artifact.as_ref()) { + classpath.push(lib_dir.join(&artifact.path)); + } else if let Some(rel) = crate::net::maven_coord_to_path(&lib.name) { + if has_local_libs { + let in_local = local_lib_dir.join(&rel); + let in_meta = lib_dir.join(&rel); + if in_local.exists() { + classpath.push(in_local); + } else if in_meta.exists() { + classpath.push(in_meta); } + } else { + classpath.push(lib_dir.join(rel)); } } - (profile.main_class, profile.game_arguments) - } else { - (vanilla_main_class.clone(), Vec::new()) - }; + } classpath.push( meta_dir @@ -328,8 +417,12 @@ pub async fn launch( clientid: "0", }; - let (upstream_jvm_args, game_args) = - build_game_args(&meta, &rule_ctx, &template_ctx, loader_game_args)?; + let (upstream_jvm_args, game_args) = build_game_args( + &merged_profile, + &rule_ctx, + &template_ctx, + extra_args.clone(), + )?; let mut jvm: Vec = vec![ format!("-Xms{}", config.memory_min.as_deref().unwrap_or("512M")), @@ -645,4 +738,75 @@ mod tests { }; assert!(legacy.minecraft_arguments.is_some()); } + + #[test] + fn installer_version_dir_name_for_forge() { + assert_eq!( + installer_version_dir_name(ModLoader::Forge, "1.20.1", "47.2.0"), + Some("1.20.1-forge-47.2.0".to_string()) + ); + } + + #[test] + fn installer_version_dir_name_for_neoforge() { + assert_eq!( + installer_version_dir_name(ModLoader::NeoForge, "1.21.1", "21.1.0"), + Some("neoforge-21.1.0".to_string()) + ); + } + + #[test] + fn installer_version_dir_name_for_non_installer_loaders() { + assert!(installer_version_dir_name(ModLoader::Vanilla, "1.20.1", "v").is_none()); + assert!(installer_version_dir_name(ModLoader::Fabric, "1.20.1", "0.14.21").is_none()); + assert!(installer_version_dir_name(ModLoader::Quilt, "1.20.1", "0.20.0").is_none()); + } + + #[test] + fn loader_profile_legacy_predicate_triggers_when_all_three_absent() { + use crate::launch_profile::model::LaunchProfile; + let legacy = LaunchProfile { + id: "1.20.1-forge-47.2.0".into(), + inherits_from: None, + main_class: Some("cpw.mods.bootstraplauncher.BootstrapLauncher".into()), + libraries: Vec::new(), + arguments: None, + minecraft_arguments: None, + asset_index: None, + assets: None, + java_version: None, + downloads: None, + release_time: None, + time: None, + type_: None, + }; + let is_legacy = legacy.inherits_from.is_none() + && legacy.arguments.is_none() + && legacy.minecraft_arguments.is_none(); + assert!(is_legacy); + } + + #[test] + fn loader_profile_legacy_predicate_skips_modern_profile_with_inherits_from() { + use crate::launch_profile::model::LaunchProfile; + let modern = LaunchProfile { + id: "1.20.1-forge-47.2.0".into(), + inherits_from: Some("1.20.1".into()), + main_class: Some("cpw.mods.bootstraplauncher.BootstrapLauncher".into()), + libraries: Vec::new(), + arguments: None, + minecraft_arguments: None, + asset_index: None, + assets: None, + java_version: None, + downloads: None, + release_time: None, + time: None, + type_: None, + }; + let is_legacy = modern.inherits_from.is_none() + && modern.arguments.is_none() + && modern.minecraft_arguments.is_none(); + assert!(!is_legacy); + } } diff --git a/src/instance/loader/mod.rs b/src/instance/loader/mod.rs index a0d3752..23c0633 100644 --- a/src/instance/loader/mod.rs +++ b/src/instance/loader/mod.rs @@ -61,8 +61,11 @@ pub(crate) fn save_profile_json( } // used by forge/neoforge. their java installer drops a version json into -// .minecraft/versions/. parses that to extract the main class and library -// list, then saves a stripped-down profile for use at launch time. +// .minecraft/versions/. we copy that file byte-for-byte to our loader +// profile cache so launch-time code sees the full upstream JSON - +// inheritsFrom, arguments.jvm, library rules, all of it - instead of a +// stripped-down version that would silently drop modern features (e.g. +// the --add-opens flags forge 1.17+ ships for java 17+ support). pub(crate) fn save_installer_profile( instance_dir: &Path, meta_dir: &Path, @@ -82,37 +85,13 @@ pub(crate) fn save_installer_profile( ))); } - #[derive(serde::Deserialize)] - #[serde(rename_all = "camelCase")] - struct InstallerVersionJson { - main_class: String, - #[serde(default)] - libraries: Vec, - } - #[derive(serde::Deserialize)] - struct InstallerLib { - name: String, - } - let raw = std::fs::read(&ver_json_path)?; - let ver: InstallerVersionJson = serde_json::from_slice(&raw).map_err(|e| { - NetError::Parse(format!( - "Invalid version JSON at {}: {e}", - ver_json_path.display() - )) - })?; - - let libs: Vec = ver - .libraries - .iter() - .map(|l| serde_json::json!({"name": l.name})) - .collect(); - let json_val = serde_json::json!({ - "mainClass": ver.main_class, - "libraries": libs - }); - - save_profile_json(meta_dir, profile_filename, &json_val) + + let profiles_dir = meta_dir.join("loader-profiles"); + std::fs::create_dir_all(&profiles_dir)?; + let profile_path = profiles_dir.join(profile_filename); + std::fs::write(&profile_path, &raw)?; + Ok(()) } pub fn get_installer(loader: ModLoader) -> Box { @@ -165,4 +144,53 @@ mod tests { assert!(!versions.is_empty()); assert!(versions.iter().any(|v| v.id == "1.20.1")); } + + #[test] + fn save_installer_profile_copies_raw_bytes_verbatim() { + use tempfile::TempDir; + let tmp = TempDir::new().unwrap(); + let instance_dir = tmp.path().join("instance"); + let meta_dir = tmp.path().join("meta"); + + // a synthetic installer version JSON with the modern arguments + // object - exactly the shape we used to strip. + let installer_json = br#"{ + "id": "1.20.1-forge-47.2.0", + "inheritsFrom": "1.20.1", + "mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher", + "libraries": [{ "name": "net.minecraftforge:forge:47.2.0" }], + "arguments": { + "game": ["--launchTarget", "forge_client"], + "jvm": ["--add-opens", "java.base/sun.security.util=cpw.mods.securejarhandler"] + } + }"#; + + let ver_dir = instance_dir + .join(".minecraft") + .join("versions") + .join("1.20.1-forge-47.2.0"); + std::fs::create_dir_all(&ver_dir).unwrap(); + let ver_json_path = ver_dir.join("1.20.1-forge-47.2.0.json"); + std::fs::write(&ver_json_path, installer_json).unwrap(); + + save_installer_profile( + &instance_dir, + &meta_dir, + "1.20.1-forge-47.2.0", + "forge-1.20.1-47.2.0.json", + ) + .unwrap(); + + let saved = std::fs::read( + meta_dir + .join("loader-profiles") + .join("forge-1.20.1-47.2.0.json"), + ) + .unwrap(); + assert_eq!( + saved, + installer_json.to_vec(), + "saved profile should be byte-for-byte identical to installer output" + ); + } } From 8d2b5b9832dcc3e9a42e4cf4c356b29f2b6e7ad9 Mon Sep 17 00:00:00 2001 From: objz Date: Mon, 25 May 2026 16:22:41 +0200 Subject: [PATCH 06/12] added: legacy Forge install_profile.json native pass-through install_forge_from_profile now writes the upstream versionInfo as compact JSON (no key reordering, no field stripping) instead of building a custom {mainClass, libraries, gameArguments} shape. legacy 1.7.10-era Forge profiles ride the same LaunchProfile + resolve + render pipeline as modern Forge. drops the no-longer-needed extract_extra_game_args helper. --- src/instance/loader/mod.rs | 60 ++++++++++++++++++++++++++++ src/net/forge.rs | 80 ++++---------------------------------- 2 files changed, 67 insertions(+), 73 deletions(-) diff --git a/src/instance/loader/mod.rs b/src/instance/loader/mod.rs index 23c0633..aa3e5d9 100644 --- a/src/instance/loader/mod.rs +++ b/src/instance/loader/mod.rs @@ -193,4 +193,64 @@ mod tests { "saved profile should be byte-for-byte identical to installer output" ); } + + #[test] + fn legacy_forge_version_info_round_trips_through_save_profile_json() { + use tempfile::TempDir; + let tmp = TempDir::new().unwrap(); + let meta_dir = tmp.path().to_path_buf(); + + // synthetic versionInfo shape from a 1.7.10 forge install_profile.json. + // legacy forge uses minecraftArguments (the flat string format) and + // typically omits inheritsFrom (the launch flow's implicit fallback + // handles that). + let version_info = serde_json::json!({ + "id": "1.7.10-Forge10.13.4.1614-1.7.10", + "mainClass": "net.minecraft.launchwrapper.Launch", + "minecraftArguments": "--username ${auth_player_name} --tweakClass cpw.mods.fml.common.launcher.FMLTweaker", + "libraries": [ + { "name": "net.minecraftforge:forge:10.13.4.1614", "url": "http://files.minecraftforge.net/maven/" }, + { "name": "net.minecraft:launchwrapper:1.9" } + ] + }); + + save_profile_json(&meta_dir, "forge-1.7.10-10.13.4.1614.json", &version_info).unwrap(); + + let saved_bytes = std::fs::read( + meta_dir + .join("loader-profiles") + .join("forge-1.7.10-10.13.4.1614.json"), + ) + .unwrap(); + + // the cached profile must parse as a LaunchProfile so the launch + // flow's render_args + resolve pipeline can consume it. + let profile: crate::launch_profile::model::LaunchProfile = + serde_json::from_slice(&saved_bytes).unwrap(); + assert_eq!(profile.id, "1.7.10-Forge10.13.4.1614-1.7.10"); + assert_eq!( + profile.main_class.as_deref(), + Some("net.minecraft.launchwrapper.Launch") + ); + assert!(profile.inherits_from.is_none()); // launch flow's implicit fallback adds it + assert!( + profile + .minecraft_arguments + .as_deref() + .unwrap() + .contains("--tweakClass") + ); + assert_eq!(profile.libraries.len(), 2); + assert_eq!( + profile.libraries[0].name, + "net.minecraftforge:forge:10.13.4.1614" + ); + assert_eq!( + profile.libraries[0].url.as_deref(), + Some("http://files.minecraftforge.net/maven/") + ); + // legacy libs typically have no `downloads.artifact` - they're + // resolved at launch time via maven_coord_to_path(name). + assert!(profile.libraries[0].downloads.is_none()); + } } diff --git a/src/net/forge.rs b/src/net/forge.rs index e3fddac..945a112 100644 --- a/src/net/forge.rs +++ b/src/net/forge.rs @@ -194,12 +194,6 @@ pub(crate) async fn install_forge_from_profile( .get("install") .ok_or_else(|| NetError::Parse("install_profile.json missing install section".into()))?; - let main_class = version_info - .get("mainClass") - .and_then(|v| v.as_str()) - .ok_or_else(|| NetError::Parse("missing versionInfo.mainClass".into()))? - .to_string(); - let libraries = version_info .get("libraries") .and_then(|v| v.as_array()) @@ -271,78 +265,18 @@ pub(crate) async fn install_forge_from_profile( download_file(client, &download_url, &dest, |_, _| {}).await?; } - // old forge profiles store game arguments (like --tweakClass) in - // minecraftArguments. extract non-template args to pass at launch. - let game_arguments: Vec = version_info - .get("minecraftArguments") - .and_then(|v| v.as_str()) - .map(extract_extra_game_args) - .unwrap_or_default(); - set_action("Saving Forge profile..."); - let lib_entries: Vec = libraries - .iter() - .filter_map(|l| { - l.get("name") - .and_then(|v| v.as_str()) - .map(|n| serde_json::json!({"name": n})) - }) - .collect(); - - let profile_json = serde_json::json!({ - "mainClass": main_class, - "libraries": lib_entries, - "gameArguments": game_arguments, - }); - - crate::instance::loader::save_profile_json(meta_dir, profile_filename, &profile_json)?; + // write the installer's versionInfo verbatim. it already has the + // mainClass, the full library list (with name + url for forge-hosted + // libs), and minecraftArguments (the legacy --tweakClass etc). the + // launch flow parses this as a LaunchProfile and - if there's no + // inheritsFrom field - implicitly inherits from the configured game + // version so vanilla libraries layer in via resolve(). + crate::instance::loader::save_profile_json(meta_dir, profile_filename, version_info)?; Ok(()) } -// pulls out game arguments from old forge's minecraftArguments string that -// the launcher doesn't already handle. skips template variables (${...}) -// and standard args like --username that we build ourselves. -fn extract_extra_game_args(minecraft_arguments: &str) -> Vec { - let handled = [ - "--username", - "--version", - "--gameDir", - "--assetsDir", - "--assetIndex", - "--uuid", - "--accessToken", - "--userProperties", - "--userType", - ]; - - let tokens: Vec<&str> = minecraft_arguments.split_whitespace().collect(); - let mut result = Vec::new(); - let mut i = 0; - while i < tokens.len() { - let token = tokens[i]; - if token.starts_with("--") { - if handled.contains(&token) { - // skip the flag and its value - i += 2; - continue; - } - result.push(token.to_string()); - // if the next token is a value (not a flag or template), include it - if i + 1 < tokens.len() && !tokens[i + 1].starts_with("--") { - let val = tokens[i + 1]; - if !val.starts_with("${") { - result.push(val.to_string()); - } - i += 2; - continue; - } - } - i += 1; - } - result -} - #[cfg(test)] mod tests { use super::*; From a7b378b17d523c1456943503048e12b3f8dd34d6 Mon Sep 17 00:00:00 2001 From: objz Date: Mon, 25 May 2026 16:56:13 +0200 Subject: [PATCH 07/12] added: Fabric/Quilt raw-bytes profile storage + migration fix stop re-serializing FabricProfile/QuiltProfile through narrow structs; new fetch_X_profile_with_raw helpers return both the parsed shape (for library downloads) and the upstream bytes (written byte-for-byte to the loader-profiles cache). also fixes a latent issue surfaced by the change: the legacy-loader migration predicate now skips Fabric and Quilt explicitly since their upstream shape matches the predicate but they have no installer JSON to recover from. --- src/instance/launch/mod.rs | 71 +++++++++++++++++++++++++++++++++++ src/instance/loader/fabric.rs | 14 +++---- src/instance/loader/mod.rs | 42 +++++++++++++++++++++ src/instance/loader/quilt.rs | 12 +++--- src/net/fabric.rs | 19 ++++++++++ src/net/quilt.rs | 19 ++++++++++ 6 files changed, 164 insertions(+), 13 deletions(-) diff --git a/src/instance/launch/mod.rs b/src/instance/launch/mod.rs index 60cf0b4..c042be8 100644 --- a/src/instance/launch/mod.rs +++ b/src/instance/launch/mod.rs @@ -115,6 +115,17 @@ async fn migrate_legacy_loader_profile_if_needed( config: &InstanceConfig, instance_dir: &Path, ) -> Result, LaunchError> { + // Fabric and Quilt fetch their profiles from a network endpoint at + // install time; there's no installer-written JSON on disk to recover + // from. their upstream profiles also happen to match the "legacy + // stripped" predicate (no inheritsFrom, no arguments), so without this + // early return every Fabric/Quilt launch would incorrectly fail + // migration. resolve() handles their lack of inheritsFrom via the + // implicit fallback in the launch flow. + if matches!(config.loader, ModLoader::Fabric | ModLoader::Quilt) { + return Ok(None); + } + let is_legacy = profile.inherits_from.is_none() && profile.arguments.is_none() && profile.minecraft_arguments.is_none(); @@ -809,4 +820,64 @@ mod tests { && modern.minecraft_arguments.is_none(); assert!(!is_legacy); } + + #[tokio::test] + async fn migrate_legacy_loader_profile_skips_fabric() { + // a fresh upstream Fabric profile happens to match the "legacy" + // shape (no inheritsFrom, no arguments, no minecraftArguments). + // make sure the migration helper recognises this is Fabric and + // returns Ok(None) instead of erroring with "reinstall Fabric". + use crate::launch_profile::model::LaunchProfile; + use chrono::Utc; + use tempfile::TempDir; + + let tmp = TempDir::new().unwrap(); + let instance_dir = tmp.path().join("instance"); + std::fs::create_dir_all(&instance_dir).unwrap(); + let profile_path = tmp.path().join("fabric-1.20.1-0.14.21.json"); + std::fs::write(&profile_path, b"{}").unwrap(); + + let upstream_fabric_shape = LaunchProfile { + id: "fabric-loader-0.14.21-1.20.1".into(), + inherits_from: None, + main_class: Some("net.fabricmc.loader.impl.launch.knot.KnotClient".into()), + libraries: Vec::new(), + arguments: None, + minecraft_arguments: None, + asset_index: None, + assets: None, + java_version: None, + downloads: None, + release_time: None, + time: None, + type_: None, + }; + + let config = InstanceConfig { + name: "test".into(), + game_version: "1.20.1".into(), + loader: ModLoader::Fabric, + loader_version: Some("0.14.21".into()), + created: Utc::now(), + last_played: None, + java_path: None, + memory_max: None, + memory_min: None, + jvm_args: Vec::new(), + resolution: None, + }; + + let result = migrate_legacy_loader_profile_if_needed( + &profile_path, + &upstream_fabric_shape, + &config, + &instance_dir, + ) + .await; + + assert!( + matches!(result, Ok(None)), + "expected Ok(None), got {result:?}" + ); + } } diff --git a/src/instance/loader/fabric.rs b/src/instance/loader/fabric.rs index a442c70..ae658cf 100644 --- a/src/instance/loader/fabric.rs +++ b/src/instance/loader/fabric.rs @@ -41,15 +41,15 @@ impl ModLoaderInstaller for FabricInstaller { _instance_dir: &Path, meta_dir: &Path, ) -> Result<(), NetError> { - let profile = - fabric_api::fetch_fabric_profile(client, game_version, loader_version).await?; + let (profile, raw_bytes) = + fabric_api::fetch_fabric_profile_with_raw(client, game_version, loader_version).await?; fabric_api::download_fabric_libraries(client, &profile, meta_dir).await?; - super::save_profile_json( - meta_dir, - &format!("fabric-{game_version}-{loader_version}.json"), - &profile, - )?; + let profiles_dir = meta_dir.join("loader-profiles"); + std::fs::create_dir_all(&profiles_dir)?; + let profile_path = + profiles_dir.join(format!("fabric-{game_version}-{loader_version}.json")); + std::fs::write(&profile_path, &raw_bytes)?; Ok(()) } diff --git a/src/instance/loader/mod.rs b/src/instance/loader/mod.rs index aa3e5d9..168ca68 100644 --- a/src/instance/loader/mod.rs +++ b/src/instance/loader/mod.rs @@ -253,4 +253,46 @@ mod tests { // resolved at launch time via maven_coord_to_path(name). assert!(profile.libraries[0].downloads.is_none()); } + + #[test] + fn raw_fabric_profile_bytes_parse_as_launch_profile() { + use tempfile::TempDir; + let tmp = TempDir::new().unwrap(); + let meta_dir = tmp.path(); + + // a synthetic upstream fabric profile shape (id, mainClass, + // libraries with name+url, no inheritsFrom, no arguments). + // simulates what the install path will write verbatim. + let upstream_bytes = br#"{ + "id": "fabric-loader-0.14.21-1.20.1", + "mainClass": "net.fabricmc.loader.impl.launch.knot.KnotClient", + "libraries": [ + { "name": "net.fabricmc:fabric-loader:0.14.21", "url": "https://maven.fabricmc.net/" }, + { "name": "net.fabricmc:intermediary:1.20.1", "url": "https://maven.fabricmc.net/" } + ] + }"#; + + let profiles_dir = meta_dir.join("loader-profiles"); + std::fs::create_dir_all(&profiles_dir).unwrap(); + let profile_path = profiles_dir.join("fabric-1.20.1-0.14.21.json"); + std::fs::write(&profile_path, upstream_bytes).unwrap(); + + let saved = std::fs::read(&profile_path).unwrap(); + assert_eq!(saved, upstream_bytes.to_vec()); + + let parsed: crate::launch_profile::model::LaunchProfile = + serde_json::from_slice(&saved).unwrap(); + assert_eq!(parsed.id, "fabric-loader-0.14.21-1.20.1"); + assert_eq!( + parsed.main_class.as_deref(), + Some("net.fabricmc.loader.impl.launch.knot.KnotClient") + ); + assert!(parsed.inherits_from.is_none()); // launch flow's implicit fallback handles it + assert!(parsed.arguments.is_none()); + assert_eq!(parsed.libraries.len(), 2); + assert_eq!( + parsed.libraries[0].url.as_deref(), + Some("https://maven.fabricmc.net/") + ); + } } diff --git a/src/instance/loader/quilt.rs b/src/instance/loader/quilt.rs index 5935f5e..3e208c7 100644 --- a/src/instance/loader/quilt.rs +++ b/src/instance/loader/quilt.rs @@ -41,14 +41,14 @@ impl ModLoaderInstaller for QuiltInstaller { _instance_dir: &Path, meta_dir: &Path, ) -> Result<(), NetError> { - let profile = quilt_api::fetch_quilt_profile(client, game_version, loader_version).await?; + let (profile, raw_bytes) = + quilt_api::fetch_quilt_profile_with_raw(client, game_version, loader_version).await?; quilt_api::download_quilt_libraries(client, &profile, meta_dir).await?; - super::save_profile_json( - meta_dir, - &format!("quilt-{game_version}-{loader_version}.json"), - &profile, - )?; + let profiles_dir = meta_dir.join("loader-profiles"); + std::fs::create_dir_all(&profiles_dir)?; + let profile_path = profiles_dir.join(format!("quilt-{game_version}-{loader_version}.json")); + std::fs::write(&profile_path, &raw_bytes)?; Ok(()) } diff --git a/src/net/fabric.rs b/src/net/fabric.rs index 86bcdc4..3587520 100644 --- a/src/net/fabric.rs +++ b/src/net/fabric.rs @@ -77,6 +77,25 @@ pub async fn fetch_fabric_profile( client.get_json(&url).await } +// like fetch_fabric_profile but also returns the raw response bytes so the +// caller can write the upstream JSON byte-for-byte to disk. used by the +// install path so we don't lose data (e.g. any future arguments field) by +// re-serializing through our narrow FabricProfile struct. +pub async fn fetch_fabric_profile_with_raw( + client: &HttpClient, + game_version: &str, + loader_version: &str, +) -> Result<(FabricProfile, Vec), NetError> { + let url = format!( + "{}/versions/loader/{}/{}/profile/json", + FABRIC_META_BASE, game_version, loader_version + ); + let raw = client.get_bytes(&url).await?; + let parsed: FabricProfile = serde_json::from_slice(&raw) + .map_err(|e| NetError::Parse(format!("Failed to parse Fabric profile: {e}")))?; + Ok((parsed, raw)) +} + // each fabric library entry has a maven coordinate and a base url. // the coordinate gets resolved to a path and combined with the url to download. pub async fn download_fabric_libraries( diff --git a/src/net/quilt.rs b/src/net/quilt.rs index 74560ec..fef5d59 100644 --- a/src/net/quilt.rs +++ b/src/net/quilt.rs @@ -74,6 +74,25 @@ pub async fn fetch_quilt_profile( client.get_json(&url).await } +// like fetch_quilt_profile but also returns the raw response bytes so the +// caller can write the upstream JSON byte-for-byte to disk. used by the +// install path so we don't lose data (e.g. any future arguments field) by +// re-serializing through our narrow QuiltProfile struct. +pub async fn fetch_quilt_profile_with_raw( + client: &HttpClient, + game_version: &str, + loader_version: &str, +) -> Result<(QuiltProfile, Vec), NetError> { + let url = format!( + "{}/versions/loader/{}/{}/profile/json", + QUILT_META_BASE, game_version, loader_version + ); + let raw = client.get_bytes(&url).await?; + let parsed: QuiltProfile = serde_json::from_slice(&raw) + .map_err(|e| NetError::Parse(format!("Failed to parse Quilt profile: {e}")))?; + Ok((parsed, raw)) +} + pub async fn download_quilt_libraries( client: &HttpClient, profile: &QuiltProfile, From 19887643bc95b9b9fd5d5810cc11b9fa7bcb22df Mon Sep 17 00:00:00 2001 From: objz Date: Mon, 25 May 2026 17:33:52 +0200 Subject: [PATCH 08/12] fixed: critical review findings - extra_args double emission and library dedup extra_args from the patches module was being passed to build_game_args (which appended it to game_args) AND emitted again via cmd.args before game_args - a regression that doubled legacy Forge + lwjgl3ify shim args. extra_args is now passed only once. also, merge_into now dedups libraries by group:artifact, preferring the child's entry - without this, Forge/NeoForge library overrides of vanilla (e.g. log4j) would silently lose to vanilla because the JVM picks the first classpath match. --- src/instance/launch/mod.rs | 27 +++-------- src/launch_profile/resolve.rs | 89 +++++++++++++++++++++++++++++++++-- 2 files changed, 90 insertions(+), 26 deletions(-) diff --git a/src/instance/launch/mod.rs b/src/instance/launch/mod.rs index c042be8..10e89d1 100644 --- a/src/instance/launch/mod.rs +++ b/src/instance/launch/mod.rs @@ -35,13 +35,10 @@ fn build_game_args( profile: &crate::launch_profile::model::LaunchProfile, rule_ctx: &crate::launch_profile::rules::RuleContext<'_>, template_ctx: &crate::launch_profile::templates::TemplateContext<'_>, - loader_game_args: Vec, ) -> Result<(Vec, Vec), LaunchError> { let rendered = crate::launch_profile::render::render_args(profile, rule_ctx, template_ctx) .map_err(|e| LaunchError::Parse(format!("Failed to render args: {e}")))?; - let mut game = rendered.game; - game.extend(loader_game_args); - Ok((rendered.jvm, game)) + Ok((rendered.jvm, rendered.game)) } // existing installs from rmcl <= 0.3.0 have meta.json files in the @@ -428,12 +425,8 @@ pub async fn launch( clientid: "0", }; - let (upstream_jvm_args, game_args) = build_game_args( - &merged_profile, - &rule_ctx, - &template_ctx, - extra_args.clone(), - )?; + let (upstream_jvm_args, game_args) = + build_game_args(&merged_profile, &rule_ctx, &template_ctx)?; let mut jvm: Vec = vec![ format!("-Xms{}", config.memory_min.as_deref().unwrap_or("512M")), @@ -652,18 +645,10 @@ mod tests { type_: None, }; - let (jvm, game_args) = build_game_args( - &profile, - &rule_ctx, - &template_ctx, - vec!["--tweakClass".into(), "foo".into()], - ) - .unwrap(); + let (jvm, game_args) = + build_game_args(&profile, &rule_ctx, &template_ctx).unwrap(); assert_eq!(jvm, vec!["-Djava.library.path=/m/natives"]); - assert_eq!( - game_args, - vec!["--username", "Player", "--tweakClass", "foo"] - ); + assert_eq!(game_args, vec!["--username", "Player"]); } #[test] diff --git a/src/launch_profile/resolve.rs b/src/launch_profile/resolve.rs index dd37a78..785494c 100644 --- a/src/launch_profile/resolve.rs +++ b/src/launch_profile/resolve.rs @@ -47,11 +47,7 @@ pub fn merge_into(child: LaunchProfile, parent: LaunchProfile) -> LaunchProfile id: child.id, inherits_from: parent.inherits_from, main_class: child.main_class.or(parent.main_class), - libraries: { - let mut libs = parent.libraries; - libs.extend(child.libraries); - libs - }, + libraries: merge_libraries(child.libraries, parent.libraries), arguments: merge_arguments(child.arguments, parent.arguments), minecraft_arguments: child.minecraft_arguments.or(parent.minecraft_arguments), asset_index: child.asset_index.or(parent.asset_index), @@ -64,6 +60,44 @@ pub fn merge_into(child: LaunchProfile, parent: LaunchProfile) -> LaunchProfile } } +// extracts the `group:artifact` portion of a maven coordinate, dropping +// version and any classifier. used as the dedup key when merging library +// lists from a child profile on top of its parent. +fn coord_key(name: &str) -> &str { + // mojang maven coords are `group:artifact:version[:classifier]`. take + // everything up to the second colon. + let mut count = 0; + for (i, b) in name.bytes().enumerate() { + if b == b':' { + count += 1; + if count == 2 { + return &name[..i]; + } + } + } + name +} + +// child entries take precedence over parent entries with the same +// group:artifact. mojang and the major third-party launchers (prism, +// multimc) all dedup this way - without it, loader overrides of vanilla +// libraries (e.g. forge bumping log4j) would lose to vanilla because the +// JVM picks the first classpath match. +fn merge_libraries( + child: Vec, + parent: Vec, +) -> Vec { + use std::collections::HashSet; + let child_keys: HashSet = child.iter().map(|l| coord_key(&l.name).to_string()).collect(); + + let mut out: Vec = parent + .into_iter() + .filter(|l| !child_keys.contains(coord_key(&l.name))) + .collect(); + out.extend(child); + out +} + fn merge_arguments(child: Option, parent: Option) -> Option { match (child, parent) { (None, None) => None, @@ -223,6 +257,51 @@ mod tests { assert_eq!(names, vec!["p1", "p2", "c1"]); } + #[test] + fn child_library_supersedes_parent_with_same_group_artifact() { + // forge declares log4j 2.17.0; vanilla declared 2.0-beta9. without + // dedup, both end up on the classpath and the JVM picks the first + // (vanilla) match - defeating forge's override. dedup keeps child's. + let mut child = empty_profile("forge"); + let mut parent = empty_profile("vanilla"); + parent.libraries = vec![ + lib("org.apache.logging.log4j:log4j-core:2.0-beta9"), + lib("org.lwjgl:lwjgl:3.3.1"), + ]; + child.libraries = vec![ + lib("org.apache.logging.log4j:log4j-core:2.17.0"), + lib("net.minecraftforge:forge:47.2.0"), + ]; + let merged = merge_into(child, parent); + let names: Vec<_> = merged.libraries.iter().map(|l| l.name.as_str()).collect(); + // parent's log4j-core is filtered (superseded by child); parent's + // lwjgl stays (no conflict); child's log4j and forge come last. + assert_eq!( + names, + vec![ + "org.lwjgl:lwjgl:3.3.1", + "org.apache.logging.log4j:log4j-core:2.17.0", + "net.minecraftforge:forge:47.2.0", + ] + ); + } + + #[test] + fn coord_key_extracts_group_artifact() { + assert_eq!(coord_key("org.lwjgl:lwjgl:3.3.1"), "org.lwjgl:lwjgl"); + assert_eq!( + coord_key("org.apache.logging.log4j:log4j-core:2.17.0"), + "org.apache.logging.log4j:log4j-core" + ); + // with classifier + assert_eq!( + coord_key("org.lwjgl:lwjgl:3.3.1:natives-linux"), + "org.lwjgl:lwjgl" + ); + // malformed (no colons) - return as-is + assert_eq!(coord_key("malformed"), "malformed"); + } + #[test] fn arguments_are_concatenated_parent_first() { let mut child = empty_profile("child"); From 412e6ff91a4bde67121fa9c3793398fb69b04c46 Mon Sep 17 00:00:00 2001 From: objz Date: Mon, 25 May 2026 18:24:06 +0200 Subject: [PATCH 09/12] fixed: review findings - robustness, dead code, missing tests several smaller fixes from the code review: - add os_version to RuleContext + OsCondition for mojang rules that constrain natives selection by host version (rare, but in the spec). - factor mojang_os_name/arch/version into a shared launch_profile::system module instead of duplicating between net/mojang.rs and launch/mod.rs. - get_json and get_bytes now retry transient failures (5xx, connect, body decode) via a shared get_with_retry envelope; previously only download_file had retry logic. - migrate_legacy_meta_if_needed falls back to the stale cached profile with a warn log on network failure instead of erroring out (offline launches now work). - tightened legacy-loader detection by requiring the unique-to-rmcl gameArguments field; clearer error when loader_version is absent. - forge legacy versionInfo write uses compact serialization (no pretty-print key reorder). - deleted dead fetch_version_meta and save_profile_json. - documented quick-play templates omission in templates::lookup. - added end-to-end test that loads a vanilla profile from disk, loads a loader profile with inheritsFrom, resolves, renders, and snapshots. --- src/instance/launch/mod.rs | 82 +++++++++++++++++-------- src/instance/loader/mod.rs | 24 +++----- src/launch_profile/mod.rs | 1 + src/launch_profile/model.rs | 7 +++ src/launch_profile/render.rs | 105 ++++++++++++++++++++++++++++++++ src/launch_profile/resolve.rs | 8 ++- src/launch_profile/rules.rs | 87 ++++++++++++++++++++++++++ src/launch_profile/system.rs | 49 +++++++++++++++ src/launch_profile/templates.rs | 9 +++ src/net/forge.rs | 16 ++++- src/net/mod.rs | 43 ++++++++++++- src/net/mojang.rs | 38 +++--------- 12 files changed, 395 insertions(+), 74 deletions(-) create mode 100644 src/launch_profile/system.rs diff --git a/src/instance/launch/mod.rs b/src/instance/launch/mod.rs index 10e89d1..5d9152d 100644 --- a/src/instance/launch/mod.rs +++ b/src/instance/launch/mod.rs @@ -60,9 +60,16 @@ async fn migrate_legacy_meta_if_needed( ); let client = crate::net::HttpClient::new(); - let manifest = crate::net::mojang::fetch_version_manifest(&client) - .await - .map_err(|e| LaunchError::Parse(format!("Failed to fetch version manifest: {e}")))?; + let manifest = match crate::net::mojang::fetch_version_manifest(&client).await { + Ok(m) => m, + Err(e) => { + tracing::warn!( + "Could not reach Mojang manifest ({e}); proceeding with the cached legacy profile. \ + Modern features like Forge's --add-opens flags may be missing until the next online launch." + ); + return Ok(None); + } + }; let entry = manifest .versions @@ -74,9 +81,15 @@ async fn migrate_legacy_meta_if_needed( )) })?; - let (_meta, raw) = crate::net::mojang::fetch_version_meta_with_raw(&client, entry) - .await - .map_err(|e| LaunchError::Parse(format!("Failed to re-fetch version meta: {e}")))?; + let (_meta, raw) = match crate::net::mojang::fetch_version_meta_with_raw(&client, entry).await { + Ok(ok) => ok, + Err(e) => { + tracing::warn!( + "Could not refetch version metadata from Mojang ({e}); proceeding with the cached legacy profile." + ); + return Ok(None); + } + }; tokio::fs::write(meta_path, &raw).await?; @@ -123,21 +136,34 @@ async fn migrate_legacy_loader_profile_if_needed( return Ok(None); } + // tightened predicate per the spec: only treat a profile as "legacy + // stripped" when our old `gameArguments` field is present. that field + // is unique to rmcl <= 0.3.0's custom shape; no upstream profile + // emits it. without this gate, an upstream profile that happens to + // omit inheritsFrom/arguments/minecraftArguments would be mistakenly + // re-extracted from the installer JSON. let is_legacy = profile.inherits_from.is_none() && profile.arguments.is_none() - && profile.minecraft_arguments.is_none(); + && profile.minecraft_arguments.is_none() + && profile.game_arguments.is_some(); if !is_legacy { return Ok(None); } - let loader_version = config.loader_version.as_deref().unwrap_or("unknown"); + let Some(loader_version) = config.loader_version.as_deref() else { + return Err(LaunchError::Parse(format!( + "Loader profile at {} is in an outdated format and the instance config has no \ + loader_version recorded. Reinstall {} for this instance.", + profile_path.display(), + config.loader + ))); + }; let Some(version_dir) = installer_version_dir_name(config.loader, &config.game_version, loader_version) else { - // Fabric/Quilt fall through to the same path but don't have an - // installer-written JSON to fall back on. Phase 6 will widen - // those structs to capture the full upstream JSON at install - // time; until then, the user reinstalls. + // unreachable today: only Vanilla/Fabric/Quilt return None, and + // Vanilla doesn't pass this code path (no loader profile to + // migrate) while Fabric/Quilt are filtered above. return Err(LaunchError::Parse(format!( "Loader profile at {} is in an outdated format. Reinstall {} for this instance.", profile_path.display(), @@ -201,17 +227,11 @@ pub async fn launch( }; let current_features = crate::launch_profile::rules::FeatureSet::default(); + let host_os_version = crate::launch_profile::system::mojang_os_version(); let rule_ctx = crate::launch_profile::rules::RuleContext { - os_name: match std::env::consts::OS { - "macos" => "osx", - other => other, - }, - arch: match std::env::consts::ARCH { - "x86" => "x86", - "x86_64" => "x86_64", - "aarch64" => "arm64", - other => other, - }, + os_name: crate::launch_profile::system::mojang_os_name(), + os_version: &host_os_version, + arch: crate::launch_profile::system::mojang_arch_name(), features: ¤t_features, }; @@ -617,6 +637,7 @@ mod tests { let features = FeatureSet::default(); let rule_ctx = RuleContext { os_name: "linux", + os_version: "6.0", arch: "x86_64", features: &features, }; @@ -642,11 +663,12 @@ mod tests { downloads: None, release_time: None, time: None, + game_arguments: None, + type_: None, }; - let (jvm, game_args) = - build_game_args(&profile, &rule_ctx, &template_ctx).unwrap(); + let (jvm, game_args) = build_game_args(&profile, &rule_ctx, &template_ctx).unwrap(); assert_eq!(jvm, vec!["-Djava.library.path=/m/natives"]); assert_eq!(game_args, vec!["--username", "Player"]); } @@ -688,6 +710,8 @@ mod tests { downloads: None, release_time: None, time: None, + game_arguments: None, + type_: None, }; assert!(stripped.arguments.is_none() && stripped.minecraft_arguments.is_none()); @@ -709,6 +733,8 @@ mod tests { downloads: None, release_time: None, time: None, + game_arguments: None, + type_: None, }; assert!(modern.arguments.is_some()); @@ -730,6 +756,8 @@ mod tests { downloads: None, release_time: None, time: None, + game_arguments: None, + type_: None, }; assert!(legacy.minecraft_arguments.is_some()); @@ -774,6 +802,8 @@ mod tests { downloads: None, release_time: None, time: None, + game_arguments: None, + type_: None, }; let is_legacy = legacy.inherits_from.is_none() @@ -798,6 +828,8 @@ mod tests { downloads: None, release_time: None, time: None, + game_arguments: None, + type_: None, }; let is_legacy = modern.inherits_from.is_none() @@ -835,6 +867,8 @@ mod tests { downloads: None, release_time: None, time: None, + game_arguments: None, + type_: None, }; diff --git a/src/instance/loader/mod.rs b/src/instance/loader/mod.rs index 168ca68..7a91f14 100644 --- a/src/instance/loader/mod.rs +++ b/src/instance/loader/mod.rs @@ -46,20 +46,6 @@ pub trait ModLoaderInstaller: Send + Sync { ) -> Result<(), NetError>; } -pub(crate) fn save_profile_json( - meta_dir: &Path, - filename: &str, - profile: &impl serde::Serialize, -) -> Result<(), NetError> { - let profiles_dir = meta_dir.join("loader-profiles"); - std::fs::create_dir_all(&profiles_dir)?; - let profile_path = profiles_dir.join(filename); - let json = serde_json::to_string_pretty(profile) - .map_err(|e| NetError::Parse(format!("Failed to serialize profile {filename}: {e}")))?; - std::fs::write(&profile_path, &json)?; - Ok(()) -} - // used by forge/neoforge. their java installer drops a version json into // .minecraft/versions/. we copy that file byte-for-byte to our loader // profile cache so launch-time code sees the full upstream JSON - @@ -214,7 +200,15 @@ mod tests { ] }); - save_profile_json(&meta_dir, "forge-1.7.10-10.13.4.1614.json", &version_info).unwrap(); + // mirror the install path: write versionInfo as compact JSON. + let profiles_dir = meta_dir.join("loader-profiles"); + std::fs::create_dir_all(&profiles_dir).unwrap(); + let serialized = serde_json::to_vec(&version_info).unwrap(); + std::fs::write( + profiles_dir.join("forge-1.7.10-10.13.4.1614.json"), + &serialized, + ) + .unwrap(); let saved_bytes = std::fs::read( meta_dir diff --git a/src/launch_profile/mod.rs b/src/launch_profile/mod.rs index 4d133da..40a2283 100644 --- a/src/launch_profile/mod.rs +++ b/src/launch_profile/mod.rs @@ -8,4 +8,5 @@ pub mod model; pub mod render; pub mod resolve; pub mod rules; +pub mod system; pub mod templates; diff --git a/src/launch_profile/model.rs b/src/launch_profile/model.rs index 822aa39..aabfa5f 100644 --- a/src/launch_profile/model.rs +++ b/src/launch_profile/model.rs @@ -31,6 +31,13 @@ pub struct LaunchProfile { pub time: Option, #[serde(rename = "type")] pub type_: Option, + // present only in rmcl <= 0.3.0's stripped loader-profile shape. + // we deserialize it so the launch-time legacy-detection predicate + // can confirm "this really is our old format, not an upstream + // profile that happens to omit arguments". skipped on serialize so + // we never propagate this field outward. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub game_arguments: Option>, } #[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)] diff --git a/src/launch_profile/render.rs b/src/launch_profile/render.rs index d1b2a48..8351f95 100644 --- a/src/launch_profile/render.rs +++ b/src/launch_profile/render.rs @@ -126,6 +126,7 @@ mod tests { downloads: None, release_time: None, time: None, + game_arguments: None, type_: None, } } @@ -140,6 +141,7 @@ mod tests { let features = FeatureSet::default(); let rule_ctx = RuleContext { os_name: "linux", + os_version: "6.0", arch: "x86_64", features: &features, }; @@ -167,6 +169,7 @@ mod tests { let features = FeatureSet::default(); let rule_ctx = RuleContext { os_name: "linux", + os_version: "6.0", arch: "x86_64", features: &features, }; @@ -197,6 +200,7 @@ mod tests { let features = FeatureSet::default(); let rule_ctx = RuleContext { os_name: "linux", + os_version: "6.0", arch: "x86_64", features: &features, }; @@ -207,6 +211,7 @@ mod tests { os: Some(OsCondition { name: Some("osx".into()), arch: None, + version: None, }), features: None, }], @@ -236,6 +241,7 @@ mod tests { let features = FeatureSet::default(); let rule_ctx = RuleContext { os_name: "linux", + os_version: "6.0", arch: "x86_64", features: &features, }; @@ -246,6 +252,7 @@ mod tests { os: Some(OsCondition { name: Some("linux".into()), arch: None, + version: None, }), features: None, }], @@ -278,6 +285,7 @@ mod tests { let features = FeatureSet::default(); let rule_ctx = RuleContext { os_name: "linux", + os_version: "6.0", arch: "x86_64", features: &features, }; @@ -289,6 +297,102 @@ mod tests { assert!(matches!(result, Err(RenderError::MissingMainClass))); } + #[tokio::test] + async fn end_to_end_resolve_then_render_modern_forge_shape() { + // exercises the full pipeline: load a synthetic vanilla profile + // from disk, load a synthetic loader profile with inheritsFrom, + // resolve the chain, then render args. catches integration bugs + // that unit tests of each layer would miss. + use crate::launch_profile::resolve; + use tempfile::TempDir; + + let tmp = TempDir::new().unwrap(); + let vanilla_path = tmp.path().join("versions").join("1.20.1").join("meta.json"); + std::fs::create_dir_all(vanilla_path.parent().unwrap()).unwrap(); + let vanilla_json = br#"{ + "id": "1.20.1", + "mainClass": "net.minecraft.client.main.Main", + "libraries": [ + { + "name": "org.lwjgl:lwjgl:3.3.1", + "downloads": { + "artifact": { + "url": "https://example.invalid/lwjgl.jar", + "path": "org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1.jar", + "sha1": "1111111111111111111111111111111111111111", + "size": 100 + } + } + } + ], + "arguments": { + "game": ["--username", "${auth_player_name}", "--version", "${version_name}"], + "jvm": ["-Djava.library.path=${natives_directory}"] + } + }"#; + std::fs::write(&vanilla_path, vanilla_json).unwrap(); + + let loader_json = r#"{ + "id": "1.20.1-forge-47.2.0", + "inheritsFrom": "1.20.1", + "mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher", + "libraries": [ + { "name": "net.minecraftforge:forge:47.2.0" } + ], + "arguments": { + "game": ["--launchTarget", "forge_client"], + "jvm": [ + "--add-opens", "java.base/sun.security.util=cpw.mods.securejarhandler" + ] + } + }"#; + let loader_profile: LaunchProfile = serde_json::from_str(loader_json).unwrap(); + + let merged = resolve::resolve(loader_profile, tmp.path()).await.unwrap(); + + let lib = PathBuf::from("/m/libraries"); + let nat = PathBuf::from("/m/natives"); + let game = PathBuf::from("/i/.minecraft"); + let assets = PathBuf::from("/m/assets"); + let template_ctx = template_fixture(&lib, &nat, &game, &assets); + let features = FeatureSet::default(); + let rule_ctx = RuleContext { + os_name: "linux", + os_version: "6.0", + arch: "x86_64", + features: &features, + }; + + let rendered = render_args(&merged, &rule_ctx, &template_ctx).unwrap(); + + // child main_class wins after merge + assert_eq!( + rendered.main_class, + "cpw.mods.bootstraplauncher.BootstrapLauncher" + ); + // game args: parent first then child + assert_eq!( + rendered.game, + vec![ + "--username", + "Player", + "--version", + "1.20.1", + "--launchTarget", + "forge_client" + ] + ); + // jvm args: parent first then child + assert_eq!( + rendered.jvm, + vec![ + "-Djava.library.path=/m/natives", + "--add-opens", + "java.base/sun.security.util=cpw.mods.securejarhandler" + ] + ); + } + #[test] fn modern_arguments_takes_precedence_over_legacy_field() { // a profile that somehow has both arguments and minecraft_arguments @@ -301,6 +405,7 @@ mod tests { let features = FeatureSet::default(); let rule_ctx = RuleContext { os_name: "linux", + os_version: "6.0", arch: "x86_64", features: &features, }; diff --git a/src/launch_profile/resolve.rs b/src/launch_profile/resolve.rs index 785494c..c61158a 100644 --- a/src/launch_profile/resolve.rs +++ b/src/launch_profile/resolve.rs @@ -56,6 +56,7 @@ pub fn merge_into(child: LaunchProfile, parent: LaunchProfile) -> LaunchProfile downloads: child.downloads.or(parent.downloads), release_time: child.release_time.or(parent.release_time), time: child.time.or(parent.time), + game_arguments: None, type_: child.type_.or(parent.type_), } } @@ -88,7 +89,10 @@ fn merge_libraries( parent: Vec, ) -> Vec { use std::collections::HashSet; - let child_keys: HashSet = child.iter().map(|l| coord_key(&l.name).to_string()).collect(); + let child_keys: HashSet = child + .iter() + .map(|l| coord_key(&l.name).to_string()) + .collect(); let mut out: Vec = parent .into_iter() @@ -171,6 +175,7 @@ mod tests { downloads: None, release_time: None, time: None, + game_arguments: None, type_: None, } } @@ -190,6 +195,7 @@ mod tests { os: Some(crate::launch_profile::rules::OsCondition { name: Some("linux".into()), arch: None, + version: None, }), features: None, } diff --git a/src/launch_profile/rules.rs b/src/launch_profile/rules.rs index c507773..e38bbda 100644 --- a/src/launch_profile/rules.rs +++ b/src/launch_profile/rules.rs @@ -16,6 +16,10 @@ pub enum RuleAction { pub struct OsCondition { pub name: Option, pub arch: Option, + // mojang occasionally constrains natives selection on os.version with a + // regex. rare in practice - when present, it's a substring/anchor match + // against the host OS version reported by `system::mojang_os_version`. + pub version: Option, } #[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)] @@ -34,6 +38,7 @@ pub struct Rule { pub struct RuleContext<'a> { pub os_name: &'a str, + pub os_version: &'a str, pub arch: &'a str, pub features: &'a FeatureSet, } @@ -63,6 +68,11 @@ fn rule_matches(rule: &Rule, ctx: &RuleContext) -> bool { { return false; } + if let Some(pattern) = &os.version + && !os_version_matches(pattern, ctx.os_version) + { + return false; + } } if let Some(required) = &rule.features && !features_match(required, ctx.features) @@ -72,6 +82,25 @@ fn rule_matches(rule: &Rule, ctx: &RuleContext) -> bool { true } +// mojang's os.version constraints are typically anchored regex patterns +// (e.g. `^10\\.`). we do a substring containment check as a defensive +// approximation that doesn't pull in the `regex` crate. when the host +// os_version is empty (Windows fallback path returns ""), version-gated +// rules don't match - which is the conservative default. +fn os_version_matches(pattern: &str, host_version: &str) -> bool { + if host_version.is_empty() { + return false; + } + // strip common regex anchors and metacharacters for substring lookup. + // good enough for the rare profile that uses os.version. + let needle = pattern + .trim_start_matches('^') + .trim_end_matches('$') + .trim_end_matches('.') + .trim_end_matches('\\'); + host_version.contains(needle) +} + fn features_match(required: &FeatureSet, current: &FeatureSet) -> bool { if let Some(want) = required.is_demo_user && current.is_demo_user.unwrap_or(false) != want @@ -93,6 +122,7 @@ mod tests { fn linux_ctx<'a>(features: &'a FeatureSet) -> RuleContext<'a> { RuleContext { os_name: "linux", + os_version: "6.0", arch: "x86_64", features, } @@ -112,6 +142,7 @@ mod tests { os: Some(OsCondition { name: Some("linux".into()), arch: None, + version: None, }), features: None, }]; @@ -127,6 +158,7 @@ mod tests { os: Some(OsCondition { name: Some("linux".into()), arch: None, + version: None, }), features: None, }]; @@ -144,6 +176,7 @@ mod tests { os: Some(OsCondition { name: Some("windows".into()), arch: None, + version: None, }), features: None, }]; @@ -165,6 +198,7 @@ mod tests { os: Some(OsCondition { name: Some("osx".into()), arch: None, + version: None, }), features: None, }, @@ -172,6 +206,7 @@ mod tests { let features = FeatureSet::default(); let osx_ctx = RuleContext { os_name: "osx", + os_version: "6.0", arch: "x86_64", features: &features, }; @@ -194,6 +229,7 @@ mod tests { os: Some(OsCondition { name: Some("linux".into()), arch: None, + version: None, }), features: None, }, @@ -210,6 +246,7 @@ mod tests { os: Some(OsCondition { name: Some("linux".into()), arch: Some("arm64".into()), + version: None, }), features: None, }]; @@ -235,6 +272,7 @@ mod tests { }; let ctx = RuleContext { os_name: "linux", + os_version: "6.0", arch: "x86_64", features: &demo_features, }; @@ -268,6 +306,55 @@ mod tests { assert!(evaluate(&rules, &ctx)); } + #[test] + fn os_version_pattern_matches_against_host() { + let rules = vec![Rule { + action: RuleAction::Allow, + os: Some(OsCondition { + name: Some("osx".into()), + arch: None, + version: Some("^10\\.".into()), + }), + features: None, + }]; + let features = FeatureSet::default(); + let ctx_match = RuleContext { + os_name: "osx", + os_version: "10.15.7", + arch: "x86_64", + features: &features, + }; + assert!(evaluate(&rules, &ctx_match)); + let ctx_mismatch = RuleContext { + os_name: "osx", + os_version: "13.2.1", + arch: "x86_64", + features: &features, + }; + assert!(!evaluate(&rules, &ctx_mismatch)); + } + + #[test] + fn os_version_pattern_does_not_match_when_host_unknown() { + let rules = vec![Rule { + action: RuleAction::Allow, + os: Some(OsCondition { + name: Some("windows".into()), + arch: None, + version: Some("^10\\.".into()), + }), + features: None, + }]; + let features = FeatureSet::default(); + let ctx = RuleContext { + os_name: "windows", + os_version: "", + arch: "x86_64", + features: &features, + }; + assert!(!evaluate(&rules, &ctx)); + } + #[test] fn rule_deserializes_from_mojang_json() { // shape lifted from real mojang library rules. diff --git a/src/launch_profile/system.rs b/src/launch_profile/system.rs new file mode 100644 index 0000000..037745d --- /dev/null +++ b/src/launch_profile/system.rs @@ -0,0 +1,49 @@ +// system-detection helpers shared by launch and install paths. mojang +// names some things differently from rust's std::env::consts (e.g. macOS +// is "osx" in mojang profile rules), so this module is the single source +// of truth for translating. + +pub fn mojang_os_name() -> &'static str { + match std::env::consts::OS { + "macos" => "osx", + other => other, + } +} + +pub fn mojang_arch_name() -> &'static str { + match std::env::consts::ARCH { + "x86" => "x86", + "x86_64" => "x86_64", + "aarch64" => "arm64", + other => other, + } +} + +// the host OS version string. mojang rules occasionally constrain natives +// selection on os.version with a regex (e.g. macOS 10.x-only natives). +// rust's stdlib doesn't expose this directly, so we synthesise something +// useful per-platform. on linux we read the kernel version; on macOS the +// product version is more useful but reading it requires shelling out so +// we fall back to the kernel version. on windows we use a similar +// approach. real-world profiles using os.version are rare; if this helper +// returns an empty string the rule evaluator treats version constraints +// as non-matching (defensive default). +pub fn mojang_os_version() -> String { + #[cfg(unix)] + { + use std::process::Command; + if let Ok(out) = Command::new("uname").arg("-r").output() + && out.status.success() + { + return String::from_utf8_lossy(&out.stdout).trim().to_string(); + } + } + #[cfg(windows)] + { + // crude - sufficient for the rare profile that needs windows version + // gating. real launchers use GetVersionEx via winapi; we can add + // that later if needed. + return std::env::var("OS").unwrap_or_default(); + } + String::new() +} diff --git a/src/launch_profile/templates.rs b/src/launch_profile/templates.rs index 08fa885..cde8dc4 100644 --- a/src/launch_profile/templates.rs +++ b/src/launch_profile/templates.rs @@ -62,6 +62,15 @@ pub fn substitute(input: &str, ctx: &TemplateContext) -> String { out } +// quick-play templates (`${quickPlayPath}`, `${quickPlaySingleplayer}`, +// `${quickPlayMultiplayer}`, `${quickPlayRealms}`) are intentionally not +// listed here. they only appear in `arguments.game` entries gated on +// `is_quick_play_*` feature flags; since rmcl never sets those flags +// (FeatureSet defaults to None across the board), the surrounding +// conditional argument is filtered out by the rule evaluator before +// template substitution even runs. if we ever expose quick-play to users, +// add the variables here AND set the corresponding feature flags in +// RuleContext at launch. fn lookup(name: &str, ctx: &TemplateContext) -> Option { Some(match name { "library_directory" => ctx.library_directory.display().to_string(), diff --git a/src/net/forge.rs b/src/net/forge.rs index 945a112..04b0516 100644 --- a/src/net/forge.rs +++ b/src/net/forge.rs @@ -266,13 +266,25 @@ pub(crate) async fn install_forge_from_profile( } set_action("Saving Forge profile..."); - // write the installer's versionInfo verbatim. it already has the + // write the installer's versionInfo as compact JSON. it already has the // mainClass, the full library list (with name + url for forge-hosted // libs), and minecraftArguments (the legacy --tweakClass etc). the // launch flow parses this as a LaunchProfile and - if there's no // inheritsFrom field - implicitly inherits from the configured game // version so vanilla libraries layer in via resolve(). - crate::instance::loader::save_profile_json(meta_dir, profile_filename, version_info)?; + // + // we use serde_json::to_vec (not the pretty-print variant via + // save_profile_json) so the written file is content-faithful: every + // field present in the installer's versionInfo round-trips. key order + // and whitespace may differ from the original installer JSON because + // the source is a serde_json::Value (which doesn't preserve order), + // but no field is silently dropped. + let profiles_dir = meta_dir.join("loader-profiles"); + std::fs::create_dir_all(&profiles_dir)?; + let profile_path = profiles_dir.join(profile_filename); + let serialized = serde_json::to_vec(version_info) + .map_err(|e| NetError::Parse(format!("Failed to serialize Forge profile: {e}")))?; + std::fs::write(&profile_path, &serialized)?; Ok(()) } diff --git a/src/net/mod.rs b/src/net/mod.rs index c5edf00..09b6be3 100644 --- a/src/net/mod.rs +++ b/src/net/mod.rs @@ -69,14 +69,53 @@ impl HttpClient { } pub async fn get_json(&self, url: &str) -> Result { - Ok(self.get(url).await?.json().await?) + get_with_retry(self, url, |resp| async move { Ok(resp.json().await?) }).await } pub async fn get_bytes(&self, url: &str) -> Result, NetError> { - Ok(self.get(url).await?.bytes().await?.to_vec()) + get_with_retry( + self, + url, + |resp| async move { Ok(resp.bytes().await?.to_vec()) }, + ) + .await } } +// shared retry envelope around `client.get(url).await? -> decode`. retries +// transient failures (timeouts, connect errors, 5xx) with exponential +// backoff. used by both get_json and get_bytes. +async fn get_with_retry(client: &HttpClient, url: &str, decode: F) -> Result +where + F: Fn(reqwest::Response) -> Fut, + Fut: std::future::Future>, +{ + let mut last_error = None; + for attempt in 0..=MAX_RETRIES { + if attempt > 0 { + let delay = RETRY_BASE_DELAY_MS * 2u64.pow(attempt - 1); + tracing::warn!( + "retrying request (attempt {}/{}): {}", + attempt + 1, + MAX_RETRIES + 1, + url + ); + tokio::time::sleep(std::time::Duration::from_millis(delay)).await; + } + + match client.get(url).await { + Ok(resp) => match decode(resp).await { + Ok(value) => return Ok(value), + Err(e) if is_retryable(&e) => last_error = Some(e), + Err(e) => return Err(e), + }, + Err(e) if is_retryable(&e) => last_error = Some(e), + Err(e) => return Err(e), + } + } + Err(last_error.unwrap()) +} + const MAX_RETRIES: u32 = 3; const RETRY_BASE_DELAY_MS: u64 = 500; diff --git a/src/net/mojang.rs b/src/net/mojang.rs index b79c154..c92105a 100644 --- a/src/net/mojang.rs +++ b/src/net/mojang.rs @@ -113,17 +113,10 @@ pub async fn fetch_version_manifest(client: &HttpClient) -> Result Result { - client.get_json(&entry.url).await -} - -// like fetch_version_meta but also returns the raw response bytes so the -// caller can write the upstream JSON byte-for-byte to disk. used by the -// install path so we don't lose data (e.g. arguments.jvm) by re-serializing -// through our narrow VersionMeta struct. +// fetches and parses a version's metadata. also returns the raw response +// bytes so the caller can write the upstream JSON byte-for-byte to disk +// - used by the install path so we don't lose data (e.g. arguments.jvm) +// by re-serializing through our narrow VersionMeta struct. pub async fn fetch_version_meta_with_raw( client: &HttpClient, entry: &VersionEntry, @@ -173,9 +166,11 @@ pub async fn download_libraries( set_action("Downloading libraries..."); let features = crate::launch_profile::rules::FeatureSet::default(); + let host_os_version = crate::launch_profile::system::mojang_os_version(); let rule_ctx = crate::launch_profile::rules::RuleContext { - os_name: mojang_os_name(), - arch: mojang_arch_name(), + os_name: crate::launch_profile::system::mojang_os_name(), + os_version: &host_os_version, + arch: crate::launch_profile::system::mojang_arch_name(), features: &features, }; @@ -293,23 +288,6 @@ pub async fn download_assets( result } -// mojang calls macOS "osx" because apparently it's still 2012 -fn mojang_os_name() -> &'static str { - match std::env::consts::OS { - "macos" => "osx", - other => other, - } -} - -fn mojang_arch_name() -> &'static str { - match std::env::consts::ARCH { - "x86" => "x86", - "x86_64" => "x86_64", - "aarch64" => "arm64", - other => other, - } -} - // bounded parallel downloader. spawns up to MAX_CONCURRENT_DOWNLOADS tasks // and feeds new ones in as each completes. collects errors but keeps going // so it downloads as much as possible before reporting the first failure. From 363ada82115518cb718f2ab3f78049a8e8de47f6 Mon Sep 17 00:00:00 2001 From: objz Date: Mon, 25 May 2026 19:05:48 +0200 Subject: [PATCH 10/12] refactored: review findings - style and abstraction cleanup - drop stale phase planning comments from launch_profile module headers. - simplify merge_libraries to HashSet<&str> (no string clones). - rewrite coord_key with match_indices (clearer intent). - read /proc/sys/kernel/osrelease on linux for mojang_os_version instead of shelling out to uname; return empty elsewhere. - add comment explaining why is_retryable excludes Parse. - shorten the launch_profile::... paths in launch/mod.rs with top-level use statements. - derive Default on LaunchProfile + nested types so test fixtures use ..Default::default() instead of listing every None field. - inline account_can_launch at its single call site, delete the helper and three tests that exercised trivially-correct one-line logic. - extract get_json_with_raw on HttpClient and save_profile_bytes in instance::loader; collapses three near-identical fetch_X_profile_with_raw helpers and three near-identical 'create dir + write bytes' blocks. --- src/instance/launch/mod.rs | 248 ++++++++++------------------------ src/instance/loader/fabric.rs | 12 +- src/instance/loader/mod.rs | 14 ++ src/instance/loader/quilt.rs | 11 +- src/launch_profile/mod.rs | 9 +- src/launch_profile/model.rs | 20 ++- src/launch_profile/render.rs | 17 +-- src/launch_profile/resolve.rs | 39 +----- src/launch_profile/rules.rs | 10 +- src/launch_profile/system.rs | 28 ++-- src/net/fabric.rs | 5 +- src/net/forge.rs | 6 +- src/net/mod.rs | 20 ++- src/net/mojang.rs | 5 +- src/net/quilt.rs | 5 +- 15 files changed, 156 insertions(+), 293 deletions(-) diff --git a/src/instance/launch/mod.rs b/src/instance/launch/mod.rs index 5d9152d..ecf6356 100644 --- a/src/instance/launch/mod.rs +++ b/src/instance/launch/mod.rs @@ -8,8 +8,12 @@ use std::path::{Path, PathBuf}; use thiserror::Error; -use crate::auth::{Account, AccountType}; +use crate::auth::AccountType; use crate::instance::models::{InstanceConfig, ModLoader}; +use crate::launch_profile::model::LaunchProfile; +use crate::launch_profile::rules::{self, FeatureSet, RuleContext}; +use crate::launch_profile::templates::TemplateContext; +use crate::launch_profile::{render, resolve, system}; #[derive(Debug, Error)] pub enum LaunchError { @@ -27,16 +31,12 @@ pub enum LaunchError { Auth(String), } -fn account_can_launch(has_microsoft_account: bool, account: &Account) -> bool { - account.account_type == AccountType::Microsoft || has_microsoft_account -} - fn build_game_args( - profile: &crate::launch_profile::model::LaunchProfile, - rule_ctx: &crate::launch_profile::rules::RuleContext<'_>, - template_ctx: &crate::launch_profile::templates::TemplateContext<'_>, + profile: &LaunchProfile, + rule_ctx: &RuleContext<'_>, + template_ctx: &TemplateContext<'_>, ) -> Result<(Vec, Vec), LaunchError> { - let rendered = crate::launch_profile::render::render_args(profile, rule_ctx, template_ctx) + let rendered = render::render_args(profile, rule_ctx, template_ctx) .map_err(|e| LaunchError::Parse(format!("Failed to render args: {e}")))?; Ok((rendered.jvm, rendered.game)) } @@ -48,9 +48,9 @@ fn build_game_args( // and overwrite the file with the raw upstream bytes. async fn migrate_legacy_meta_if_needed( meta_path: &Path, - profile: &crate::launch_profile::model::LaunchProfile, + profile: &LaunchProfile, game_version: &str, -) -> Result, LaunchError> { +) -> Result, LaunchError> { if profile.arguments.is_some() || profile.minecraft_arguments.is_some() { return Ok(None); } @@ -93,7 +93,7 @@ async fn migrate_legacy_meta_if_needed( tokio::fs::write(meta_path, &raw).await?; - let refreshed: crate::launch_profile::model::LaunchProfile = serde_json::from_slice(&raw) + let refreshed: LaunchProfile = serde_json::from_slice(&raw) .map_err(|e| LaunchError::Parse(format!("Failed to parse refreshed meta: {e}")))?; Ok(Some(refreshed)) } @@ -121,10 +121,10 @@ fn installer_version_dir_name( // rebuild from the installer's original JSON if it's still on disk. async fn migrate_legacy_loader_profile_if_needed( profile_path: &Path, - profile: &crate::launch_profile::model::LaunchProfile, + profile: &LaunchProfile, config: &InstanceConfig, instance_dir: &Path, -) -> Result, LaunchError> { +) -> Result, LaunchError> { // Fabric and Quilt fetch their profiles from a network endpoint at // install time; there's no installer-written JSON on disk to recover // from. their upstream profiles also happen to match the "legacy @@ -196,10 +196,9 @@ async fn migrate_legacy_loader_profile_if_needed( let raw = tokio::fs::read(&installer_json_path).await?; tokio::fs::write(profile_path, &raw).await?; - let refreshed: crate::launch_profile::model::LaunchProfile = serde_json::from_slice(&raw) - .map_err(|e| { - LaunchError::Parse(format!("Failed to parse refreshed loader profile: {e}")) - })?; + let refreshed: LaunchProfile = serde_json::from_slice(&raw).map_err(|e| { + LaunchError::Parse(format!("Failed to parse refreshed loader profile: {e}")) + })?; Ok(Some(refreshed)) } @@ -219,19 +218,18 @@ pub async fn launch( if !meta_path.exists() { return Err(LaunchError::MetaNotFound(meta_path.display().to_string())); } - let meta: crate::launch_profile::model::LaunchProfile = - serde_json::from_slice(&tokio::fs::read(&meta_path).await?)?; + let meta: LaunchProfile = serde_json::from_slice(&tokio::fs::read(&meta_path).await?)?; let meta = match migrate_legacy_meta_if_needed(&meta_path, &meta, &config.game_version).await? { Some(refreshed) => refreshed, None => meta, }; - let current_features = crate::launch_profile::rules::FeatureSet::default(); - let host_os_version = crate::launch_profile::system::mojang_os_version(); - let rule_ctx = crate::launch_profile::rules::RuleContext { - os_name: crate::launch_profile::system::mojang_os_name(), + let current_features = FeatureSet::default(); + let host_os_version = system::mojang_os_version(); + let rule_ctx = RuleContext { + os_name: system::mojang_os_name(), os_version: &host_os_version, - arch: crate::launch_profile::system::mojang_arch_name(), + arch: system::mojang_arch_name(), features: ¤t_features, }; @@ -256,41 +254,40 @@ pub async fn launch( // if needed, and resolve `inheritsFrom` against the vanilla parent (which // the vanilla meta migration above ensured is fresh on disk). when no // loader is configured we use the already-loaded vanilla meta directly. - let merged_profile: crate::launch_profile::model::LaunchProfile = - if let Some(filename) = &profile_filename { - let profile_path = meta_dir.join("loader-profiles").join(filename); - if !profile_path.exists() { - return Err(LaunchError::MetaNotFound( - profile_path.display().to_string(), - )); - } - let mut loader_profile: crate::launch_profile::model::LaunchProfile = - serde_json::from_slice(&tokio::fs::read(&profile_path).await?)?; - - if let Some(refreshed) = migrate_legacy_loader_profile_if_needed( - &profile_path, - &loader_profile, - config, - &instance_dir, - ) - .await? - { - loader_profile = refreshed; - } + let merged_profile: LaunchProfile = if let Some(filename) = &profile_filename { + let profile_path = meta_dir.join("loader-profiles").join(filename); + if !profile_path.exists() { + return Err(LaunchError::MetaNotFound( + profile_path.display().to_string(), + )); + } + let mut loader_profile: LaunchProfile = + serde_json::from_slice(&tokio::fs::read(&profile_path).await?)?; - // legacy installer-written profiles (and any loader profile that - // omits inheritsFrom) still need to be layered over vanilla. set - // the inherit explicitly so resolve() walks the chain. - if loader_profile.inherits_from.is_none() { - loader_profile.inherits_from = Some(config.game_version.clone()); - } + if let Some(refreshed) = migrate_legacy_loader_profile_if_needed( + &profile_path, + &loader_profile, + config, + &instance_dir, + ) + .await? + { + loader_profile = refreshed; + } - crate::launch_profile::resolve::resolve(loader_profile, meta_dir) - .await - .map_err(|e| LaunchError::Parse(format!("Failed to resolve loader profile: {e}")))? - } else { - meta.clone() - }; + // legacy installer-written profiles (and any loader profile that + // omits inheritsFrom) still need to be layered over vanilla. set + // the inherit explicitly so resolve() walks the chain. + if loader_profile.inherits_from.is_none() { + loader_profile.inherits_from = Some(config.game_version.clone()); + } + + resolve::resolve(loader_profile, meta_dir) + .await + .map_err(|e| LaunchError::Parse(format!("Failed to resolve loader profile: {e}")))? + } else { + meta.clone() + }; let main_class = merged_profile .main_class @@ -308,7 +305,7 @@ pub async fn launch( let mut classpath: Vec = Vec::new(); for lib in &merged_profile.libraries { if let Some(rules) = &lib.rules - && !crate::launch_profile::rules::evaluate(rules, &rule_ctx) + && !rules::evaluate(rules, &rule_ctx) { continue; } @@ -373,7 +370,10 @@ pub async fn launch( .cloned() { Some(acc) => { - if !account_can_launch(account_store.has_microsoft_account(), &acc) { + // offline accounts can only launch if a microsoft account exists + // (proves the user owns minecraft). + if acc.account_type != AccountType::Microsoft && !account_store.has_microsoft_account() + { return Err(LaunchError::Auth( "Offline accounts require a Microsoft account that owns Minecraft".to_owned(), )); @@ -425,7 +425,7 @@ pub async fn launch( .join("versions") .join(&config.game_version) .join("natives"); - let template_ctx = crate::launch_profile::templates::TemplateContext { + let template_ctx = TemplateContext { library_directory: &lib_dir, classpath_separator: sep, version_name: &config.game_version, @@ -589,25 +589,12 @@ pub async fn launch( #[cfg(test)] mod tests { use super::*; - use crate::auth::{Account, AccountType}; - - fn test_account(account_type: AccountType) -> Account { - Account { - uuid: "00000000-0000-0000-0000-000000000001".to_owned(), - username: "TestPlayer".to_owned(), - account_type, - active: true, - refresh_token: Some("refresh".to_owned()), - cached_mc_token: None, - cached_mc_token_expires_at: None, - } - } #[test] fn build_game_args_renders_upstream_arguments_and_appends_loader_args() { use crate::launch_profile::model::{Argument, Arguments, LaunchProfile}; use crate::launch_profile::rules::{FeatureSet, RuleContext}; - use crate::launch_profile::templates::TemplateContext; + use TemplateContext; use std::path::PathBuf; let lib = PathBuf::from("/m/libraries"); @@ -656,16 +643,7 @@ mod tests { "-Djava.library.path=${natives_directory}".into(), )], }), - minecraft_arguments: None, - asset_index: None, - assets: None, - java_version: None, - downloads: None, - release_time: None, - time: None, - game_arguments: None, - - type_: None, + ..Default::default() }; let (jvm, game_args) = build_game_args(&profile, &rule_ctx, &template_ctx).unwrap(); @@ -673,46 +651,15 @@ mod tests { assert_eq!(game_args, vec!["--username", "Player"]); } - #[test] - fn offline_account_cannot_launch_without_microsoft_account() { - let offline = test_account(AccountType::Offline); - - assert!(!account_can_launch(false, &offline)); - } - - #[test] - fn offline_account_can_launch_with_microsoft_account() { - let offline = test_account(AccountType::Offline); - - assert!(account_can_launch(true, &offline)); - } - - #[test] - fn microsoft_account_can_launch_without_offline_gate() { - let microsoft = test_account(AccountType::Microsoft); - - assert!(account_can_launch(false, µsoft)); - } - #[test] fn migration_predicate_triggers_when_both_argument_fields_absent() { - use crate::launch_profile::model::LaunchProfile; + use LaunchProfile; let stripped = LaunchProfile { id: "1.20.1".into(), inherits_from: None, main_class: Some("net.test.Main".into()), libraries: Vec::new(), - arguments: None, - minecraft_arguments: None, - asset_index: None, - assets: None, - java_version: None, - downloads: None, - release_time: None, - time: None, - game_arguments: None, - - type_: None, + ..Default::default() }; assert!(stripped.arguments.is_none() && stripped.minecraft_arguments.is_none()); } @@ -726,23 +673,14 @@ mod tests { main_class: Some("net.test.Main".into()), libraries: Vec::new(), arguments: Some(Arguments::default()), - minecraft_arguments: None, - asset_index: None, - assets: None, - java_version: None, - downloads: None, - release_time: None, - time: None, - game_arguments: None, - - type_: None, + ..Default::default() }; assert!(modern.arguments.is_some()); } #[test] fn migration_predicate_skips_when_legacy_minecraft_arguments_present() { - use crate::launch_profile::model::LaunchProfile; + use LaunchProfile; let legacy = LaunchProfile { id: "1.7.10".into(), inherits_from: None, @@ -750,15 +688,7 @@ mod tests { libraries: Vec::new(), arguments: None, minecraft_arguments: Some("--username Player".into()), - asset_index: None, - assets: None, - java_version: None, - downloads: None, - release_time: None, - time: None, - game_arguments: None, - - type_: None, + ..Default::default() }; assert!(legacy.minecraft_arguments.is_some()); } @@ -788,23 +718,13 @@ mod tests { #[test] fn loader_profile_legacy_predicate_triggers_when_all_three_absent() { - use crate::launch_profile::model::LaunchProfile; + use LaunchProfile; let legacy = LaunchProfile { id: "1.20.1-forge-47.2.0".into(), inherits_from: None, main_class: Some("cpw.mods.bootstraplauncher.BootstrapLauncher".into()), libraries: Vec::new(), - arguments: None, - minecraft_arguments: None, - asset_index: None, - assets: None, - java_version: None, - downloads: None, - release_time: None, - time: None, - game_arguments: None, - - type_: None, + ..Default::default() }; let is_legacy = legacy.inherits_from.is_none() && legacy.arguments.is_none() @@ -814,23 +734,13 @@ mod tests { #[test] fn loader_profile_legacy_predicate_skips_modern_profile_with_inherits_from() { - use crate::launch_profile::model::LaunchProfile; + use LaunchProfile; let modern = LaunchProfile { id: "1.20.1-forge-47.2.0".into(), inherits_from: Some("1.20.1".into()), main_class: Some("cpw.mods.bootstraplauncher.BootstrapLauncher".into()), libraries: Vec::new(), - arguments: None, - minecraft_arguments: None, - asset_index: None, - assets: None, - java_version: None, - downloads: None, - release_time: None, - time: None, - game_arguments: None, - - type_: None, + ..Default::default() }; let is_legacy = modern.inherits_from.is_none() && modern.arguments.is_none() @@ -844,7 +754,7 @@ mod tests { // shape (no inheritsFrom, no arguments, no minecraftArguments). // make sure the migration helper recognises this is Fabric and // returns Ok(None) instead of erroring with "reinstall Fabric". - use crate::launch_profile::model::LaunchProfile; + use LaunchProfile; use chrono::Utc; use tempfile::TempDir; @@ -859,17 +769,7 @@ mod tests { inherits_from: None, main_class: Some("net.fabricmc.loader.impl.launch.knot.KnotClient".into()), libraries: Vec::new(), - arguments: None, - minecraft_arguments: None, - asset_index: None, - assets: None, - java_version: None, - downloads: None, - release_time: None, - time: None, - game_arguments: None, - - type_: None, + ..Default::default() }; let config = InstanceConfig { diff --git a/src/instance/loader/fabric.rs b/src/instance/loader/fabric.rs index ae658cf..ae69dcb 100644 --- a/src/instance/loader/fabric.rs +++ b/src/instance/loader/fabric.rs @@ -44,13 +44,11 @@ impl ModLoaderInstaller for FabricInstaller { let (profile, raw_bytes) = fabric_api::fetch_fabric_profile_with_raw(client, game_version, loader_version).await?; fabric_api::download_fabric_libraries(client, &profile, meta_dir).await?; - - let profiles_dir = meta_dir.join("loader-profiles"); - std::fs::create_dir_all(&profiles_dir)?; - let profile_path = - profiles_dir.join(format!("fabric-{game_version}-{loader_version}.json")); - std::fs::write(&profile_path, &raw_bytes)?; - + super::save_profile_bytes( + meta_dir, + &format!("fabric-{game_version}-{loader_version}.json"), + &raw_bytes, + )?; Ok(()) } } diff --git a/src/instance/loader/mod.rs b/src/instance/loader/mod.rs index 7a91f14..e670341 100644 --- a/src/instance/loader/mod.rs +++ b/src/instance/loader/mod.rs @@ -46,6 +46,20 @@ pub trait ModLoaderInstaller: Send + Sync { ) -> Result<(), NetError>; } +// writes raw profile JSON bytes to meta_dir/loader-profiles/. +// callers that already have the upstream bytes (fabric/quilt http fetch, +// legacy forge versionInfo extract) use this directly to keep the on-disk +// file byte-for-byte identical to the source. +pub(crate) fn save_profile_bytes( + meta_dir: &Path, + filename: &str, + bytes: &[u8], +) -> std::io::Result<()> { + let profiles_dir = meta_dir.join("loader-profiles"); + std::fs::create_dir_all(&profiles_dir)?; + std::fs::write(profiles_dir.join(filename), bytes) +} + // used by forge/neoforge. their java installer drops a version json into // .minecraft/versions/. we copy that file byte-for-byte to our loader // profile cache so launch-time code sees the full upstream JSON - diff --git a/src/instance/loader/quilt.rs b/src/instance/loader/quilt.rs index 3e208c7..8ad4b8b 100644 --- a/src/instance/loader/quilt.rs +++ b/src/instance/loader/quilt.rs @@ -44,12 +44,11 @@ impl ModLoaderInstaller for QuiltInstaller { let (profile, raw_bytes) = quilt_api::fetch_quilt_profile_with_raw(client, game_version, loader_version).await?; quilt_api::download_quilt_libraries(client, &profile, meta_dir).await?; - - let profiles_dir = meta_dir.join("loader-profiles"); - std::fs::create_dir_all(&profiles_dir)?; - let profile_path = profiles_dir.join(format!("quilt-{game_version}-{loader_version}.json")); - std::fs::write(&profile_path, &raw_bytes)?; - + super::save_profile_bytes( + meta_dir, + &format!("quilt-{game_version}-{loader_version}.json"), + &raw_bytes, + )?; Ok(()) } } diff --git a/src/launch_profile/mod.rs b/src/launch_profile/mod.rs index 40a2283..3e5cbde 100644 --- a/src/launch_profile/mod.rs +++ b/src/launch_profile/mod.rs @@ -1,8 +1,7 @@ -// foundation primitives for parsing, merging, and rendering mojang-format -// launch profiles. consumed by the vanilla launcher, forge/neoforge, -// fabric/quilt - anything that reads a mojang-style version JSON. -// -// phase 3 adds the `resolve` module that walks `inheritsFrom` chains. +// primitives for parsing, merging, and rendering mojang-format launch +// profiles. consumed by the vanilla launcher and the loader install paths +// (forge/neoforge/fabric/quilt) - anything that reads a mojang-style +// version JSON. pub mod model; pub mod render; diff --git a/src/launch_profile/model.rs b/src/launch_profile/model.rs index aabfa5f..127c3ed 100644 --- a/src/launch_profile/model.rs +++ b/src/launch_profile/model.rs @@ -4,16 +4,12 @@ // the fields we care about; unknown fields are silently dropped (serde // default behavior) - which is fine because we write upstream JSON // byte-for-byte on the install side. -// -// no consumers yet. phase 2 wires this into the vanilla launch path; -// phase 3 adds inheritsFrom resolution; phase 4 swaps the forge/neoforge -// installer profile writer over to use this shape. use serde::{Deserialize, Serialize}; use super::rules::Rule; -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct LaunchProfile { pub id: String, @@ -65,7 +61,7 @@ pub enum ArgumentValue { Multiple(Vec), } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] pub struct Library { pub name: String, pub downloads: Option, @@ -73,12 +69,12 @@ pub struct Library { pub url: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] pub struct LibraryDownloads { pub artifact: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] pub struct Artifact { pub url: String, pub path: String, @@ -86,7 +82,7 @@ pub struct Artifact { pub size: u64, } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] pub struct AssetIndex { pub id: String, pub url: String, @@ -95,19 +91,19 @@ pub struct AssetIndex { pub total_size: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct JavaVersion { pub component: Option, pub major_version: u32, } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] pub struct VersionDownloads { pub client: Download, } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] pub struct Download { pub url: String, pub sha1: String, diff --git a/src/launch_profile/render.rs b/src/launch_profile/render.rs index 8351f95..f156b09 100644 --- a/src/launch_profile/render.rs +++ b/src/launch_profile/render.rs @@ -115,19 +115,8 @@ mod tests { fn minimal_profile() -> LaunchProfile { LaunchProfile { id: "test".into(), - inherits_from: None, main_class: Some("net.test.Main".into()), - libraries: Vec::new(), - arguments: None, - minecraft_arguments: None, - asset_index: None, - assets: None, - java_version: None, - downloads: None, - release_time: None, - time: None, - game_arguments: None, - type_: None, + ..Default::default() } } @@ -211,7 +200,7 @@ mod tests { os: Some(OsCondition { name: Some("osx".into()), arch: None, - version: None, + ..Default::default() }), features: None, }], @@ -252,7 +241,7 @@ mod tests { os: Some(OsCondition { name: Some("linux".into()), arch: None, - version: None, + ..Default::default() }), features: None, }], diff --git a/src/launch_profile/resolve.rs b/src/launch_profile/resolve.rs index c61158a..fe1b50e 100644 --- a/src/launch_profile/resolve.rs +++ b/src/launch_profile/resolve.rs @@ -67,16 +67,9 @@ pub fn merge_into(child: LaunchProfile, parent: LaunchProfile) -> LaunchProfile fn coord_key(name: &str) -> &str { // mojang maven coords are `group:artifact:version[:classifier]`. take // everything up to the second colon. - let mut count = 0; - for (i, b) in name.bytes().enumerate() { - if b == b':' { - count += 1; - if count == 2 { - return &name[..i]; - } - } - } - name + let mut it = name.match_indices(':').map(|(i, _)| i); + it.next(); + it.next().map_or(name, |i| &name[..i]) } // child entries take precedence over parent entries with the same @@ -89,10 +82,7 @@ fn merge_libraries( parent: Vec, ) -> Vec { use std::collections::HashSet; - let child_keys: HashSet = child - .iter() - .map(|l| coord_key(&l.name).to_string()) - .collect(); + let child_keys: HashSet<&str> = child.iter().map(|l| coord_key(&l.name)).collect(); let mut out: Vec = parent .into_iter() @@ -164,28 +154,14 @@ mod tests { fn empty_profile(id: &str) -> LaunchProfile { LaunchProfile { id: id.into(), - inherits_from: None, - main_class: None, - libraries: Vec::new(), - arguments: None, - minecraft_arguments: None, - asset_index: None, - assets: None, - java_version: None, - downloads: None, - release_time: None, - time: None, - game_arguments: None, - type_: None, + ..Default::default() } } fn lib(name: &str) -> Library { Library { name: name.into(), - downloads: None, - rules: None, - url: None, + ..Default::default() } } @@ -194,8 +170,7 @@ mod tests { action: RuleAction::Allow, os: Some(crate::launch_profile::rules::OsCondition { name: Some("linux".into()), - arch: None, - version: None, + ..Default::default() }), features: None, } diff --git a/src/launch_profile/rules.rs b/src/launch_profile/rules.rs index e38bbda..cccfe67 100644 --- a/src/launch_profile/rules.rs +++ b/src/launch_profile/rules.rs @@ -142,7 +142,7 @@ mod tests { os: Some(OsCondition { name: Some("linux".into()), arch: None, - version: None, + ..Default::default() }), features: None, }]; @@ -158,7 +158,7 @@ mod tests { os: Some(OsCondition { name: Some("linux".into()), arch: None, - version: None, + ..Default::default() }), features: None, }]; @@ -176,7 +176,7 @@ mod tests { os: Some(OsCondition { name: Some("windows".into()), arch: None, - version: None, + ..Default::default() }), features: None, }]; @@ -198,7 +198,7 @@ mod tests { os: Some(OsCondition { name: Some("osx".into()), arch: None, - version: None, + ..Default::default() }), features: None, }, @@ -229,7 +229,7 @@ mod tests { os: Some(OsCondition { name: Some("linux".into()), arch: None, - version: None, + ..Default::default() }), features: None, }, diff --git a/src/launch_profile/system.rs b/src/launch_profile/system.rs index 037745d..f41da61 100644 --- a/src/launch_profile/system.rs +++ b/src/launch_profile/system.rs @@ -21,29 +21,17 @@ pub fn mojang_arch_name() -> &'static str { // the host OS version string. mojang rules occasionally constrain natives // selection on os.version with a regex (e.g. macOS 10.x-only natives). -// rust's stdlib doesn't expose this directly, so we synthesise something -// useful per-platform. on linux we read the kernel version; on macOS the -// product version is more useful but reading it requires shelling out so -// we fall back to the kernel version. on windows we use a similar -// approach. real-world profiles using os.version are rare; if this helper -// returns an empty string the rule evaluator treats version constraints -// as non-matching (defensive default). +// rust's stdlib doesn't expose this, so we read it where it's cheap and +// reliable: linux via /proc/sys/kernel/osrelease, other platforms return +// empty. when the host string is empty, version-gated rules don't match +// (conservative default in the rule evaluator) - which is fine because +// real-world profiles using os.version are vanishingly rare. pub fn mojang_os_version() -> String { - #[cfg(unix)] + #[cfg(target_os = "linux")] { - use std::process::Command; - if let Ok(out) = Command::new("uname").arg("-r").output() - && out.status.success() - { - return String::from_utf8_lossy(&out.stdout).trim().to_string(); + if let Ok(s) = std::fs::read_to_string("/proc/sys/kernel/osrelease") { + return s.trim().to_string(); } } - #[cfg(windows)] - { - // crude - sufficient for the rare profile that needs windows version - // gating. real launchers use GetVersionEx via winapi; we can add - // that later if needed. - return std::env::var("OS").unwrap_or_default(); - } String::new() } diff --git a/src/net/fabric.rs b/src/net/fabric.rs index 3587520..aea2644 100644 --- a/src/net/fabric.rs +++ b/src/net/fabric.rs @@ -90,10 +90,7 @@ pub async fn fetch_fabric_profile_with_raw( "{}/versions/loader/{}/{}/profile/json", FABRIC_META_BASE, game_version, loader_version ); - let raw = client.get_bytes(&url).await?; - let parsed: FabricProfile = serde_json::from_slice(&raw) - .map_err(|e| NetError::Parse(format!("Failed to parse Fabric profile: {e}")))?; - Ok((parsed, raw)) + client.get_json_with_raw(&url, "Fabric profile").await } // each fabric library entry has a maven coordinate and a base url. diff --git a/src/net/forge.rs b/src/net/forge.rs index 04b0516..ba11279 100644 --- a/src/net/forge.rs +++ b/src/net/forge.rs @@ -279,13 +279,9 @@ pub(crate) async fn install_forge_from_profile( // and whitespace may differ from the original installer JSON because // the source is a serde_json::Value (which doesn't preserve order), // but no field is silently dropped. - let profiles_dir = meta_dir.join("loader-profiles"); - std::fs::create_dir_all(&profiles_dir)?; - let profile_path = profiles_dir.join(profile_filename); let serialized = serde_json::to_vec(version_info) .map_err(|e| NetError::Parse(format!("Failed to serialize Forge profile: {e}")))?; - std::fs::write(&profile_path, &serialized)?; - + crate::instance::loader::save_profile_bytes(meta_dir, profile_filename, &serialized)?; Ok(()) } diff --git a/src/net/mod.rs b/src/net/mod.rs index 09b6be3..cdd05cb 100644 --- a/src/net/mod.rs +++ b/src/net/mod.rs @@ -80,6 +80,21 @@ impl HttpClient { ) .await } + + // fetch JSON and also keep the raw bytes. used by install paths that + // want both the parsed shape (for downloading libraries from it) and + // the original bytes (to write byte-for-byte to the loader-profiles + // cache, so any field we don't know about survives). + pub async fn get_json_with_raw( + &self, + url: &str, + label: &str, + ) -> Result<(T, Vec), NetError> { + let raw = self.get_bytes(url).await?; + let parsed: T = serde_json::from_slice(&raw) + .map_err(|e| NetError::Parse(format!("Failed to parse {label}: {e}")))?; + Ok((parsed, raw)) + } } // shared retry envelope around `client.get(url).await? -> decode`. retries @@ -183,7 +198,10 @@ async fn download_file_once( Ok(()) } -// body decode errors and timeouts are worth retrying, but a 404 or disk error isn't +// body decode errors and timeouts are worth retrying, but a 404 or disk +// error isn't. Parse errors stay non-retryable: by the time we hit one +// the response body has fully arrived, so the failure means the upstream +// returned malformed JSON - retrying won't fix that. fn is_retryable(err: &NetError) -> bool { match err { NetError::Http(e) => e.is_timeout() || e.is_body() || e.is_connect(), diff --git a/src/net/mojang.rs b/src/net/mojang.rs index c92105a..d8b0dcc 100644 --- a/src/net/mojang.rs +++ b/src/net/mojang.rs @@ -121,10 +121,7 @@ pub async fn fetch_version_meta_with_raw( client: &HttpClient, entry: &VersionEntry, ) -> Result<(VersionMeta, Vec), NetError> { - let raw = client.get_bytes(&entry.url).await?; - let parsed: VersionMeta = serde_json::from_slice(&raw) - .map_err(|e| NetError::Parse(format!("Failed to parse version meta: {e}")))?; - Ok((parsed, raw)) + client.get_json_with_raw(&entry.url, "version meta").await } pub async fn download_client_jar( diff --git a/src/net/quilt.rs b/src/net/quilt.rs index fef5d59..eb9ceec 100644 --- a/src/net/quilt.rs +++ b/src/net/quilt.rs @@ -87,10 +87,7 @@ pub async fn fetch_quilt_profile_with_raw( "{}/versions/loader/{}/{}/profile/json", QUILT_META_BASE, game_version, loader_version ); - let raw = client.get_bytes(&url).await?; - let parsed: QuiltProfile = serde_json::from_slice(&raw) - .map_err(|e| NetError::Parse(format!("Failed to parse Quilt profile: {e}")))?; - Ok((parsed, raw)) + client.get_json_with_raw(&url, "Quilt profile").await } pub async fn download_quilt_libraries( From d88ab085b5b684bd72257993be87262ca4257798 Mon Sep 17 00:00:00 2001 From: objz Date: Mon, 25 May 2026 20:58:05 +0200 Subject: [PATCH 11/12] fixed: forge/neoforge libraries from instance .minecraft/libraries not on classpath modern forge's installer places some libraries (notably the bootstrap library that defines ForgeBootstrap / BootstrapLauncher) into the instance's .minecraft/libraries/ rather than the shared meta cache. the unified classpath loop introduced in the rework checked downloads.artifact first and pushed meta_dir/libraries/ without ever consulting the local installer dir for libraries that had downloads.artifact set. resolve a single relative path per library (downloads.artifact.path if present, else maven_coord_to_path(name)), then for has_local_libs check the local installer dir first regardless of which path source we used. fixes ClassNotFoundException at launch on forge 1.20.6 and likely similar versions. --- src/instance/launch/mod.rs | 44 +++++++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/src/instance/launch/mod.rs b/src/instance/launch/mod.rs index ecf6356..1556c66 100644 --- a/src/instance/launch/mod.rs +++ b/src/instance/launch/mod.rs @@ -310,21 +310,32 @@ pub async fn launch( continue; } - if let Some(artifact) = lib.downloads.as_ref().and_then(|d| d.artifact.as_ref()) { - classpath.push(lib_dir.join(&artifact.path)); - } else if let Some(rel) = crate::net::maven_coord_to_path(&lib.name) { - if has_local_libs { - let in_local = local_lib_dir.join(&rel); - let in_meta = lib_dir.join(&rel); - if in_local.exists() { - classpath.push(in_local); - } else if in_meta.exists() { - classpath.push(in_meta); - } - } else { - classpath.push(lib_dir.join(rel)); + // resolve a relative path for this library. prefer downloads.artifact.path + // when present (vanilla-style), fall back to maven_coord_to_path(name) + // for loader-style entries that only have a coord. + let rel: PathBuf = match lib + .downloads + .as_ref() + .and_then(|d| d.artifact.as_ref()) + .map(|a| PathBuf::from(&a.path)) + .or_else(|| crate::net::maven_coord_to_path(&lib.name).map(PathBuf::from)) + { + Some(p) => p, + None => continue, + }; + + // for forge/neoforge, the installer drops some libs (notably the + // bootstrap library) into /.minecraft/libraries/ rather + // than the shared meta cache. check there first regardless of + // whether the lib has a downloads.artifact entry. + if has_local_libs { + let in_local = local_lib_dir.join(&rel); + if in_local.exists() { + classpath.push(in_local); + continue; } } + classpath.push(lib_dir.join(rel)); } classpath.push( @@ -425,10 +436,12 @@ pub async fn launch( .join("versions") .join(&config.game_version) .join("natives"); + let version_type = merged_profile.type_.as_deref().unwrap_or("release"); let template_ctx = TemplateContext { library_directory: &lib_dir, classpath_separator: sep, version_name: &config.game_version, + version_type, natives_directory: &natives_dir, classpath: &cp_str, game_directory: &minecraft_dir, @@ -443,6 +456,8 @@ pub async fn launch( launcher_name: "rmcl", launcher_version: env!("CARGO_PKG_VERSION"), clientid: "0", + resolution_width: "", + resolution_height: "", }; let (upstream_jvm_args, game_args) = @@ -620,6 +635,9 @@ mod tests { launcher_name: "rmcl", launcher_version: "test", clientid: "0", + version_type: "release", + resolution_width: "", + resolution_height: "", }; let features = FeatureSet::default(); let rule_ctx = RuleContext { From f45351841c0ff477bb15f37c29a15a4ce911e0ed Mon Sep 17 00:00:00 2001 From: objz Date: Mon, 25 May 2026 20:58:19 +0200 Subject: [PATCH 12/12] fixed: quick-play feature flags + missing version_type template two related bugs surfaced by manual testing on 1.20.6 vanilla and fabric: 1. FeatureSet missing has_quick_plays_support / is_quick_play_singleplayer / is_quick_play_multiplayer / is_quick_play_realms. mojang's profiles gate the quick-play arguments behind these features. serde silently dropped the unknown fields during deserialization, leaving an empty FeatureSet that matched any context, so the conditional arguments emitted raw ${quickPlay*} placeholders to the JVM that template substitution then warned about. add the fields explicitly and extend features_match to reject any required feature the context doesn't set. 2. TemplateContext missing version_type. ${version_type} is an unconditional template in modern vanilla's arguments.game (renders the profile's "type" field, e.g. "release"). without it, the substitution emitted a literal ${version_type} to minecraft and warned. add the field, plumb the merged profile's type_ value through, default to "release" when absent. --- src/instance/launch/mod.rs | 4 --- src/launch_profile/render.rs | 1 + src/launch_profile/rules.rs | 47 +++++++++++++++++++++++---------- src/launch_profile/templates.rs | 4 +++ 4 files changed, 38 insertions(+), 18 deletions(-) diff --git a/src/instance/launch/mod.rs b/src/instance/launch/mod.rs index 1556c66..ba9086c 100644 --- a/src/instance/launch/mod.rs +++ b/src/instance/launch/mod.rs @@ -456,8 +456,6 @@ pub async fn launch( launcher_name: "rmcl", launcher_version: env!("CARGO_PKG_VERSION"), clientid: "0", - resolution_width: "", - resolution_height: "", }; let (upstream_jvm_args, game_args) = @@ -636,8 +634,6 @@ mod tests { launcher_version: "test", clientid: "0", version_type: "release", - resolution_width: "", - resolution_height: "", }; let features = FeatureSet::default(); let rule_ctx = RuleContext { diff --git a/src/launch_profile/render.rs b/src/launch_profile/render.rs index f156b09..a75e14c 100644 --- a/src/launch_profile/render.rs +++ b/src/launch_profile/render.rs @@ -95,6 +95,7 @@ mod tests { library_directory, classpath_separator: ":", version_name: "1.20.1", + version_type: "release", natives_directory, classpath: "a.jar:b.jar", game_directory, diff --git a/src/launch_profile/rules.rs b/src/launch_profile/rules.rs index cccfe67..2d6d899 100644 --- a/src/launch_profile/rules.rs +++ b/src/launch_profile/rules.rs @@ -27,6 +27,15 @@ pub struct OsCondition { pub struct FeatureSet { pub is_demo_user: Option, pub has_custom_resolution: Option, + // quick-play feature flags (1.20+). rmcl never sets these, so any rule + // gated on them is filtered out by features_match. listing them + // explicitly is what makes that filter work: without the fields, + // serde would silently drop them during deserialization, leaving a + // FeatureSet::default that matches everything. + pub has_quick_plays_support: Option, + pub is_quick_play_singleplayer: Option, + pub is_quick_play_multiplayer: Option, + pub is_quick_play_realms: Option, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] @@ -102,17 +111,27 @@ fn os_version_matches(pattern: &str, host_version: &str) -> bool { } fn features_match(required: &FeatureSet, current: &FeatureSet) -> bool { - if let Some(want) = required.is_demo_user - && current.is_demo_user.unwrap_or(false) != want - { - return false; - } - if let Some(want) = required.has_custom_resolution - && current.has_custom_resolution.unwrap_or(false) != want - { - return false; - } - true + let pairs = [ + (required.is_demo_user, current.is_demo_user), + (required.has_custom_resolution, current.has_custom_resolution), + ( + required.has_quick_plays_support, + current.has_quick_plays_support, + ), + ( + required.is_quick_play_singleplayer, + current.is_quick_play_singleplayer, + ), + ( + required.is_quick_play_multiplayer, + current.is_quick_play_multiplayer, + ), + (required.is_quick_play_realms, current.is_quick_play_realms), + ]; + pairs.iter().all(|(req, cur)| match req { + Some(want) => cur.unwrap_or(false) == *want, + None => true, + }) } #[cfg(test)] @@ -263,12 +282,12 @@ mod tests { os: None, features: Some(FeatureSet { is_demo_user: Some(true), - has_custom_resolution: None, + ..Default::default() }), }]; let demo_features = FeatureSet { is_demo_user: Some(true), - has_custom_resolution: None, + ..Default::default() }; let ctx = RuleContext { os_name: "linux", @@ -286,7 +305,7 @@ mod tests { os: None, features: Some(FeatureSet { is_demo_user: Some(true), - has_custom_resolution: None, + ..Default::default() }), }]; let features = FeatureSet::default(); diff --git a/src/launch_profile/templates.rs b/src/launch_profile/templates.rs index cde8dc4..4d53058 100644 --- a/src/launch_profile/templates.rs +++ b/src/launch_profile/templates.rs @@ -14,6 +14,7 @@ pub struct TemplateContext<'a> { pub library_directory: &'a Path, pub classpath_separator: &'a str, pub version_name: &'a str, + pub version_type: &'a str, pub natives_directory: &'a Path, pub classpath: &'a str, pub game_directory: &'a Path, @@ -76,6 +77,7 @@ fn lookup(name: &str, ctx: &TemplateContext) -> Option { "library_directory" => ctx.library_directory.display().to_string(), "classpath_separator" => ctx.classpath_separator.to_string(), "version_name" => ctx.version_name.to_string(), + "version_type" => ctx.version_type.to_string(), "natives_directory" => ctx.natives_directory.display().to_string(), "classpath" => ctx.classpath.to_string(), "game_directory" => ctx.game_directory.display().to_string(), @@ -109,6 +111,7 @@ mod tests { library_directory, classpath_separator: ":", version_name: "1.20.1", + version_type: "release", natives_directory, classpath: "a.jar:b.jar", game_directory, @@ -237,6 +240,7 @@ mod tests { launcher_name: "rmcl", launcher_version: "0.3.0", clientid: "0", + version_type: "release", }; assert_eq!(substitute("${user_properties}", &ctx), "${version_name}"); }