1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use crate::launcher::options::LaunchOptions;
5use crate::models::loader::LoaderType;
6use crate::models::minecraft::{AssetItem, GameArgEntry, MinecraftVersionJson};
7
8pub struct LoaderContext<'a> {
13 pub loader_type: Option<&'a LoaderType>,
15 pub version_id: Option<&'a str>,
17 pub extra_game_args: &'a [String],
19 pub extra_jvm_args: &'a [String],
21}
22
23pub fn get_game_arguments(
35 options: &LaunchOptions,
36 version_json: &MinecraftVersionJson,
37 loader: Option<&LoaderContext<'_>>,
38) -> Vec<String> {
39 let ph = build_game_placeholders(options, version_json, loader);
40 let mut args: Vec<String> = Vec::new();
41
42 if let Some(raw) = &version_json.minecraft_arguments {
43 for token in raw.split_whitespace() {
45 args.push(replace_placeholders(token, &ph));
46 }
47 } else if let Some(arguments) = &version_json.arguments {
48 if let Some(game) = &arguments.game {
49 for entry in game {
50 if let GameArgEntry::Plain(s) = entry {
51 args.push(replace_placeholders(s, &ph));
52 }
53 }
55 }
56 }
57
58 if let Some(ctx) = loader {
60 for arg in ctx.extra_game_args {
61 let resolved = replace_placeholders(arg, &ph);
62 if !args.contains(&resolved) {
63 args.push(resolved);
64 }
65 }
66 }
67
68 for extra in &options.game_args {
69 args.push(replace_placeholders(extra, &ph));
70 }
71
72 args
73}
74
75pub fn get_jvm_arguments(
87 options: &LaunchOptions,
88 version_json: &MinecraftVersionJson,
89 natives_path: &Path,
90 loader: Option<&LoaderContext<'_>>,
91) -> Vec<String> {
92 let mut args: Vec<String> = Vec::new();
93 let natives_str = natives_path.to_string_lossy().into_owned();
94
95 args.push(format!("-Xms{}", options.memory.min));
97 args.push(format!("-Xmx{}", options.memory.max));
98
99 args.push("-XX:+UnlockExperimentalVMOptions".into());
101 args.push("-XX:G1NewSizePercent=20".into());
102 args.push("-XX:G1ReservePercent=20".into());
103 args.push("-XX:MaxGCPauseMillis=50".into());
104 args.push("-XX:G1HeapRegionSize=32M".into());
105
106 if let Some(ctx) = loader {
108 if matches!(ctx.loader_type, Some(LoaderType::Forge) | Some(LoaderType::NeoForge)) {
109 args.push("-Dfml.ignoreInvalidMinecraftCertificates=true".into());
110 args.push("-Dfml.ignorePatchDiscrepancies=true".into());
111 }
112 }
113
114 if version_json.minecraft_arguments.is_none() {
117 match std::env::consts::OS {
118 "macos" => args.push("-XstartOnFirstThread".into()),
120 "linux" => args.push("-Xss1M".into()),
122 "windows" => args.push(
124 "-XX:HeapDumpPath=MojangTricksIntelDriversForPerformance_javaw.exe_minecraft.exe.heapdump".into()
125 ),
126 _ => {}
127 }
128 }
129
130 args.push(format!("-Djava.library.path={natives_str}"));
132 args.push(format!("-Djna.tmpdir={natives_str}"));
133 args.push(format!("-Dorg.lwjgl.system.SharedLibraryExtractPath={natives_str}"));
134 args.push(format!("-Dio.netty.native.workdir={natives_str}"));
135
136 if let Some(arguments) = &version_json.arguments {
138 if let Some(jvm_entries) = &arguments.jvm {
139 let ph = build_jvm_placeholders(options, version_json, &natives_str, "");
140
141 let mut skip_next = false;
142 for val in jvm_entries {
143 if skip_next {
144 skip_next = false;
145 continue;
146 }
147
148 if let Some(s) = val.as_str() {
149 if s == "-cp" || s == "--classpath" {
150 skip_next = true;
151 continue;
152 }
153 if s.contains("${classpath}") {
154 continue;
155 }
156 args.push(replace_placeholders(s, &ph));
157 } else if val.is_object() {
158 if jvm_rule_passes(val) {
159 for token in extract_jvm_value(val) {
160 if token == "-cp" || token == "--classpath" || token.contains("${classpath}") {
161 continue;
162 }
163 args.push(replace_placeholders(&token, &ph));
164 }
165 }
166 }
167 }
168 }
169 }
170
171 if options.bypass_offline {
173 args.push("-Dminecraft.api.auth.host=https://invalidAuthServer.invalid".into());
174 args.push("-Dminecraft.api.account.host=https://invalidAccountServer.invalid".into());
175 args.push("-Dminecraft.api.session.host=https://invalidSessionServer.invalid".into());
176 args.push("-Dminecraft.api.services.host=https://invalidServicesServer.invalid".into());
177 }
178
179 for extra in &options.jvm_args {
181 args.push(extra.clone());
182 }
183
184 if let Some(ctx) = loader {
186 for arg in ctx.extra_jvm_args {
187 args.push(arg.clone());
188 }
189 }
190
191 args
192}
193
194pub fn get_classpath(
206 version_json: &MinecraftVersionJson,
207 bundle: &[AssetItem],
208) -> (Vec<String>, String) {
209 let jar_paths: Vec<PathBuf> = bundle
210 .iter()
211 .filter_map(|item| match item {
212 AssetItem::Asset { path, .. } => {
213 if path.ends_with(".jar") && !path.contains("/assets/objects/") {
214 Some(PathBuf::from(path))
215 } else {
216 None
217 }
218 }
219 _ => None,
220 })
221 .collect();
222
223 let mut deduped = deduplicate_classpath(jar_paths);
224
225 let has_slf4j2 = deduped.iter().any(|p| {
228 p.file_name()
229 .map_or(false, |f| f.to_string_lossy().contains("log4j-slf4j2-impl"))
230 });
231 if has_slf4j2 {
232 deduped.retain(|p| {
233 let name = p.file_name().map_or(String::new(), |f| f.to_string_lossy().into_owned());
234 !name.contains("log4j-slf4j-impl") || name.contains("log4j-slf4j2-impl")
236 });
237 }
238
239 let sep = classpath_separator();
240 let cp = deduped
241 .iter()
242 .map(|p| p.to_string_lossy().into_owned())
243 .collect::<Vec<_>>()
244 .join(sep);
245
246 let main_class = version_json.main_class.clone().unwrap_or_default();
247 (vec!["-cp".into(), cp], main_class)
248}
249
250fn build_game_placeholders<'a>(
253 options: &'a LaunchOptions,
254 version_json: &'a MinecraftVersionJson,
255 loader: Option<&LoaderContext<'_>>,
256) -> HashMap<&'a str, String> {
257 let auth = &options.authenticator;
258
259 let assets_id = version_json
260 .asset_index
261 .as_ref()
262 .map(|ai| ai.id.clone())
263 .or_else(|| version_json.assets.clone())
264 .unwrap_or_default();
265
266 let user_type = if version_json.id.starts_with("1.16") {
268 "Xbox".to_string()
269 } else if auth.xbox_account.is_some() {
270 "msa".to_string()
271 } else {
272 "legacy".to_string()
273 };
274
275 let version_name = loader
277 .and_then(|ctx| ctx.version_id)
278 .unwrap_or(version_json.id.as_str())
279 .to_owned();
280
281 let auth_xuid = auth
283 .xbox_account
284 .as_ref()
285 .map(|x| x.xuid.clone())
286 .unwrap_or_else(|| auth.access_token.clone());
287
288 let clientid = auth
290 .client_id
291 .clone()
292 .or_else(|| auth.client_token.clone())
293 .unwrap_or_else(|| auth.access_token.clone());
294
295 let game_directory = options.save_dir().to_string_lossy().into_owned();
296
297 let is_legacy = matches!(version_json.assets.as_deref(), Some("legacy") | Some("pre-1.6"));
299 let assets_root = if is_legacy {
300 options.path.join("resources").to_string_lossy().into_owned()
301 } else {
302 options.path.join("assets").to_string_lossy().into_owned()
303 };
304
305 let mut ph: HashMap<&str, String> = HashMap::new();
306 ph.insert("auth_player_name", auth.name.clone());
307 ph.insert("version_name", version_name);
308 ph.insert("game_directory", game_directory);
309 ph.insert("assets_root", assets_root.clone());
310 ph.insert("game_assets", assets_root); ph.insert("assets_index_name", assets_id);
312 ph.insert("auth_uuid", auth.uuid.clone());
313 ph.insert("auth_access_token", auth.access_token.clone());
314 ph.insert("auth_session", auth.access_token.clone()); ph.insert("auth_xuid", auth_xuid);
316 ph.insert("user_type", user_type);
317 ph.insert("version_type", version_json.version_type.clone());
318 ph.insert(
319 "user_properties",
320 auth.user_properties.clone().unwrap_or_else(|| "{}".into()),
321 );
322 ph.insert("clientid", clientid);
323 ph
324}
325
326fn build_jvm_placeholders<'a>(
327 options: &'a LaunchOptions,
328 _version_json: &'a MinecraftVersionJson,
329 natives_str: &'a str,
330 classpath: &'a str,
331) -> HashMap<&'a str, String> {
332 let mut ph: HashMap<&str, String> = HashMap::new();
333 ph.insert("natives_directory", natives_str.to_owned());
334 ph.insert("launcher_name", "minecraft-java-rs-core".into());
335 ph.insert("launcher_version", env!("CARGO_PKG_VERSION").into());
336 ph.insert(
337 "classpath_separator",
338 classpath_separator().to_string(),
339 );
340 ph.insert("classpath", classpath.to_owned());
341 ph.insert(
342 "library_directory",
343 options.path.join("libraries").to_string_lossy().into_owned(),
344 );
345 ph
346}
347
348fn replace_placeholders(s: &str, ph: &HashMap<&str, String>) -> String {
349 let mut result = s.to_owned();
350 for (key, val) in ph {
351 result = result.replace(&format!("${{{key}}}"), val);
352 }
353 result
354}
355
356fn jvm_rule_passes(val: &serde_json::Value) -> bool {
359 let rules = match val.get("rules").and_then(|r| r.as_array()) {
360 Some(r) => r,
361 None => return true,
362 };
363
364 let os_name = std::env::consts::OS;
365 let mojang_os = match os_name {
366 "macos" => "osx",
367 "windows" => "windows",
368 "linux" => "linux",
369 other => other,
370 };
371
372 let mut result = false;
373 for rule in rules {
374 let action = rule.get("action").and_then(|a| a.as_str()).unwrap_or("disallow");
375 let allow = action == "allow";
376
377 if let Some(os) = rule.get("os") {
378 let name_matches = os
379 .get("name")
380 .and_then(|n| n.as_str())
381 .map(|n| n == mojang_os)
382 .unwrap_or(true);
383
384 if name_matches {
385 result = allow;
386 }
387 } else {
388 result = allow;
389 }
390 }
391
392 result
393}
394
395fn extract_jvm_value(val: &serde_json::Value) -> Vec<String> {
396 match val.get("value") {
397 Some(serde_json::Value::String(s)) => vec![s.clone()],
398 Some(serde_json::Value::Array(arr)) => arr
399 .iter()
400 .filter_map(|v| v.as_str().map(str::to_owned))
401 .collect(),
402 _ => vec![],
403 }
404}
405
406pub fn classpath_separator() -> &'static str {
409 if cfg!(target_os = "windows") {
410 ";"
411 } else {
412 ":"
413 }
414}
415
416fn deduplicate_classpath(paths: Vec<PathBuf>) -> Vec<PathBuf> {
424 let mut entries: HashMap<String, (String, PathBuf)> = HashMap::new();
426 let mut key_order: Vec<String> = Vec::new();
428
429 for path in paths {
430 let components: Vec<_> = path.components().collect();
431 let n = components.len();
432
433 let (artifact_key, version_dir) = if n >= 3 {
434 let version = components[n - 2].as_os_str().to_string_lossy().into_owned();
435 let artifact = components[n - 3].as_os_str().to_string_lossy().into_owned();
436 let stem = path
440 .file_stem()
441 .map(|s| s.to_string_lossy().into_owned())
442 .unwrap_or_default();
443 let base = format!("{artifact}-{version}");
444 let key = if stem.starts_with(&format!("{base}-")) {
445 let classifier = &stem[base.len() + 1..];
446 format!("{artifact}-{classifier}")
447 } else {
448 artifact
449 };
450 (key, version)
451 } else {
452 (path.to_string_lossy().into_owned(), String::new())
454 };
455
456 if let Some((existing_ver, existing_path)) = entries.get_mut(&artifact_key) {
457 if version_is_higher(&version_dir, existing_ver) {
459 *existing_ver = version_dir;
460 *existing_path = path;
461 }
462 } else {
463 key_order.push(artifact_key.clone());
464 entries.insert(artifact_key, (version_dir, path));
465 }
466 }
467
468 key_order
469 .into_iter()
470 .filter_map(|k| entries.remove(&k).map(|(_, p)| p))
471 .collect()
472}
473
474fn version_is_higher(a: &str, b: &str) -> bool {
475 if let (Ok(va), Ok(vb)) = (semver::Version::parse(a), semver::Version::parse(b)) {
476 return va > vb;
477 }
478
479 let parse_parts = |s: &str| -> Vec<u64> {
481 s.split(|c: char| c == '.' || c == '-')
482 .map(|p| p.parse::<u64>().unwrap_or(0))
483 .collect()
484 };
485
486 parse_parts(a) > parse_parts(b)
487}
488
489#[cfg(test)]
492mod tests {
493 use super::*;
494 use std::path::PathBuf;
495
496 fn make_opts() -> LaunchOptions {
497 use crate::launcher::options::{JavaOptions, LoaderConfig, MemoryConfig, ScreenConfig};
498 use crate::models::minecraft::Authenticator;
499 LaunchOptions {
500 path: PathBuf::from("/mc"),
501 version: "1.20.4".into(),
502 authenticator: Authenticator {
503 access_token: "token123".into(),
504 name: "Steve".into(),
505 uuid: "uuid-1234".into(),
506 xbox_account: None,
507 user_properties: None,
508 client_id: None,
509 client_token: None,
510 },
511 timeout_secs: 10,
512 download_concurrency: 5,
513 verify_concurrency: 4,
514 memory: MemoryConfig { min: "512M".into(), max: "4G".into() },
515 java: JavaOptions::default(),
516 loader: LoaderConfig::default(),
517 screen: ScreenConfig::default(),
518 verify: false,
519 game_args: vec![],
520 jvm_args: vec![],
521 instance: None,
522 url: None,
523 mcp: None,
524 intel_enabled_mac: false,
525 bypass_offline: false,
526 skip_bundle_check: false,
527 }
528 }
529
530 fn bare_version() -> MinecraftVersionJson {
531 MinecraftVersionJson {
532 id: "1.20.4".into(),
533 version_type: "release".into(),
534 assets: Some("17".into()),
535 asset_index: None,
536 downloads: None,
537 libraries: vec![],
538 arguments: None,
539 minecraft_arguments: None,
540 java_version: None,
541 main_class: Some("net.minecraft.client.main.Main".into()),
542 has_natives: false,
543 }
544 }
545
546 #[test]
549 fn legacy_game_args_split_and_replace() {
550 let opts = make_opts();
551 let mut vj = bare_version();
552 vj.minecraft_arguments = Some(
553 "--username ${auth_player_name} --version ${version_name}".into(),
554 );
555 let args = get_game_arguments(&opts, &vj, None);
556 assert_eq!(args[0], "--username");
557 assert_eq!(args[1], "Steve");
558 assert_eq!(args[2], "--version");
559 assert_eq!(args[3], "1.20.4");
560 }
561
562 #[test]
563 fn modern_game_args_plain_strings_only() {
564 use crate::models::minecraft::Arguments;
565 let opts = make_opts();
566 let mut vj = bare_version();
567 vj.arguments = Some(Arguments {
568 game: Some(vec![
569 GameArgEntry::Plain("--username".into()),
570 GameArgEntry::Plain("${auth_player_name}".into()),
571 GameArgEntry::Conditional(serde_json::json!({"rules": [], "value": "--demo"})),
572 ]),
573 jvm: None,
574 });
575 let args = get_game_arguments(&opts, &vj, None);
576 assert_eq!(args.len(), 2);
577 assert_eq!(args[0], "--username");
578 assert_eq!(args[1], "Steve");
579 }
580
581 #[test]
582 fn extra_game_args_appended() {
583 let mut opts = make_opts();
584 opts.game_args = vec!["--demo".into()];
585 let mut vj = bare_version();
586 vj.minecraft_arguments = Some("--username ${auth_player_name}".into());
587 let args = get_game_arguments(&opts, &vj, None);
588 assert_eq!(args.last().unwrap(), "--demo");
589 }
590
591 #[test]
592 fn user_type_is_msa_when_xbox_account_present() {
593 use crate::models::minecraft::XboxAccount;
594 let mut opts = make_opts();
595 opts.authenticator.xbox_account = Some(XboxAccount { xuid: "x123".into() });
596 let mut vj = bare_version();
597 vj.minecraft_arguments = Some("${user_type}".into());
598 let args = get_game_arguments(&opts, &vj, None);
599 assert_eq!(args[0], "msa");
600 }
601
602 #[test]
603 fn user_type_is_xbox_on_116() {
604 let opts = make_opts();
605 let mut vj = bare_version();
606 vj.id = "1.16.5".into();
607 vj.minecraft_arguments = Some("${user_type}".into());
608 let args = get_game_arguments(&opts, &vj, None);
609 assert_eq!(args[0], "Xbox");
610 }
611
612 #[test]
613 fn loader_version_id_overrides_version_name() {
614 let opts = make_opts();
615 let mut vj = bare_version();
616 vj.minecraft_arguments = Some("${version_name}".into());
617 let ctx = LoaderContext {
618 loader_type: Some(&LoaderType::Forge),
619 version_id: Some("1.20.4-forge-47.4.20"),
620 extra_game_args: &[],
621 extra_jvm_args: &[],
622 };
623 let args = get_game_arguments(&opts, &vj, Some(&ctx));
624 assert_eq!(args[0], "1.20.4-forge-47.4.20");
625 }
626
627 #[test]
628 fn loader_extra_game_args_merged_deduped() {
629 let opts = make_opts();
630 let mut vj = bare_version();
631 vj.minecraft_arguments = Some("--username ${auth_player_name}".into());
632 let extra = vec!["--launchTarget".into(), "fmlclient".into(), "--username".into()];
633 let ctx = LoaderContext {
634 loader_type: Some(&LoaderType::Forge),
635 version_id: None,
636 extra_game_args: &extra,
637 extra_jvm_args: &[],
638 };
639 let args = get_game_arguments(&opts, &vj, Some(&ctx));
640 let username_count = args.iter().filter(|a| *a == "--username").count();
642 assert_eq!(username_count, 1);
643 assert!(args.contains(&"--launchTarget".to_string()));
644 assert!(args.contains(&"fmlclient".to_string()));
645 }
646
647 #[test]
648 fn auth_session_placeholder_resolved() {
649 let opts = make_opts();
650 let mut vj = bare_version();
651 vj.minecraft_arguments = Some("${auth_session}".into());
652 let args = get_game_arguments(&opts, &vj, None);
653 assert_eq!(args[0], "token123");
654 }
655
656 #[test]
657 fn clientid_falls_back_to_client_token() {
658 let mut opts = make_opts();
659 opts.authenticator.client_token = Some("ct-abc".into());
660 let mut vj = bare_version();
661 vj.minecraft_arguments = Some("${clientid}".into());
662 let args = get_game_arguments(&opts, &vj, None);
663 assert_eq!(args[0], "ct-abc");
664 }
665
666 #[test]
667 fn clientid_falls_back_to_access_token() {
668 let opts = make_opts(); let mut vj = bare_version();
670 vj.minecraft_arguments = Some("${clientid}".into());
671 let args = get_game_arguments(&opts, &vj, None);
672 assert_eq!(args[0], "token123");
673 }
674
675 #[test]
678 fn jvm_args_contain_memory_and_natives() {
679 let opts = make_opts();
680 let vj = bare_version();
681 let natives = PathBuf::from("/mc/versions/1.20.4/natives");
682 let args = get_jvm_arguments(&opts, &vj, &natives, None);
683 assert!(args.contains(&"-Xms512M".to_string()));
684 assert!(args.contains(&"-Xmx4G".to_string()));
685 assert!(args.iter().any(|a| a.contains("-Djava.library.path=")));
686 }
687
688 #[test]
689 fn jvm_args_contain_gc_flags() {
690 let opts = make_opts();
691 let args = get_jvm_arguments(&opts, &bare_version(), Path::new("/n"), None);
692 assert!(args.contains(&"-XX:+UnlockExperimentalVMOptions".to_string()));
693 assert!(args.contains(&"-XX:G1NewSizePercent=20".to_string()));
694 assert!(args.contains(&"-XX:MaxGCPauseMillis=50".to_string()));
695 }
696
697 #[test]
698 fn jvm_args_contain_jna_dirs() {
699 let opts = make_opts();
700 let natives = Path::new("/natives");
701 let args = get_jvm_arguments(&opts, &bare_version(), natives, None);
702 assert!(args.iter().any(|a| a.starts_with("-Djna.tmpdir=")));
703 assert!(args.iter().any(|a| a.starts_with("-Dorg.lwjgl.system.SharedLibraryExtractPath=")));
704 assert!(args.iter().any(|a| a.starts_with("-Dio.netty.native.workdir=")));
705 }
706
707 #[test]
708 fn jvm_args_forge_adds_fml_flags() {
709 let opts = make_opts();
710 let ctx = LoaderContext {
711 loader_type: Some(&LoaderType::Forge),
712 version_id: None,
713 extra_game_args: &[],
714 extra_jvm_args: &[],
715 };
716 let args = get_jvm_arguments(&opts, &bare_version(), Path::new("/n"), Some(&ctx));
717 assert!(args.contains(&"-Dfml.ignoreInvalidMinecraftCertificates=true".to_string()));
718 assert!(args.contains(&"-Dfml.ignorePatchDiscrepancies=true".to_string()));
719 }
720
721 #[test]
722 fn jvm_args_fabric_no_fml_flags() {
723 let opts = make_opts();
724 let ctx = LoaderContext {
725 loader_type: Some(&LoaderType::Fabric),
726 version_id: None,
727 extra_game_args: &[],
728 extra_jvm_args: &[],
729 };
730 let args = get_jvm_arguments(&opts, &bare_version(), Path::new("/n"), Some(&ctx));
731 assert!(!args.iter().any(|a| a.contains("fml")));
732 }
733
734 #[test]
735 fn bypass_offline_adds_sys_properties() {
736 let mut opts = make_opts();
737 opts.bypass_offline = true;
738 let args = get_jvm_arguments(&opts, &bare_version(), Path::new("/n"), None);
739 assert!(args.iter().any(|a| a.contains("invalidAuthServer")));
740 }
741
742 #[test]
743 fn jvm_args_no_classpath_entry() {
744 let opts = make_opts();
745 let args = get_jvm_arguments(&opts, &bare_version(), Path::new("/n"), None);
746 assert!(!args.iter().any(|a| a == "-cp" || a == "--classpath"));
747 }
748
749 #[test]
752 fn classpath_contains_jar_paths() {
753 let vj = bare_version();
754 let bundle = vec![
755 AssetItem::Asset {
756 path: "/mc/libraries/net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar".into(),
757 sha1: "aaa".into(),
758 size: 100,
759 url: "http://x".into(),
760 },
761 AssetItem::Asset {
762 path: "/mc/assets/objects/aa/aabbcc".into(),
763 sha1: "bbb".into(),
764 size: 10,
765 url: "http://y".into(),
766 },
767 ];
768 let (cp_args, main) = get_classpath(&vj, &bundle);
769 assert_eq!(cp_args[0], "-cp");
770 let cp = &cp_args[1];
771 assert!(cp.contains("jopt-simple-5.0.4.jar"));
772 assert!(!cp.contains("aabbcc"));
773 assert_eq!(main, "net.minecraft.client.main.Main");
774 }
775
776 #[test]
777 fn classpath_deduplicates_lower_version() {
778 let vj = bare_version();
779 let bundle = vec![
780 AssetItem::Asset {
781 path: "/mc/libraries/com/google/guava/guava/21.0/guava-21.0.jar".into(),
782 sha1: "a".into(),
783 size: 1,
784 url: "http://x".into(),
785 },
786 AssetItem::Asset {
787 path: "/mc/libraries/com/google/guava/guava/32.1.2/guava-32.1.2.jar".into(),
788 sha1: "b".into(),
789 size: 2,
790 url: "http://x".into(),
791 },
792 ];
793 let (cp_args, _) = get_classpath(&vj, &bundle);
794 let cp = &cp_args[1];
795 assert!(cp.contains("32.1.2"), "should keep higher version: {cp}");
796 assert!(!cp.contains("21.0"), "should drop lower version: {cp}");
797 }
798
799 #[test]
800 fn classpath_preserves_loader_first_order() {
801 let vj = bare_version();
802 let bundle = vec![
804 AssetItem::Asset {
805 path: "/loader/forge/libraries/net/minecraftforge/forge/1.0/forge-1.0.jar".into(),
806 sha1: "a".into(),
807 size: 1,
808 url: "http://x".into(),
809 },
810 AssetItem::Asset {
811 path: "/mc/libraries/org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1.jar".into(),
812 sha1: "b".into(),
813 size: 2,
814 url: "http://x".into(),
815 },
816 ];
817 let (cp_args, _) = get_classpath(&vj, &bundle);
818 let cp = &cp_args[1];
819 let forge_pos = cp.find("forge-1.0.jar").unwrap();
820 let lwjgl_pos = cp.find("lwjgl-3.3.1.jar").unwrap();
821 assert!(forge_pos < lwjgl_pos, "loader lib should come before vanilla lib");
822 }
823
824 #[test]
825 fn classpath_removes_slf4j1_when_slf4j2_present() {
826 let vj = bare_version();
827 let bundle = vec![
828 AssetItem::Asset {
829 path: "/loader/forge/libraries/log4j/log4j-slf4j2-impl/18.0/log4j-slf4j2-impl-18.0.jar".into(),
830 sha1: "a".into(),
831 size: 1,
832 url: "http://x".into(),
833 },
834 AssetItem::Asset {
835 path: "/mc/libraries/log4j/log4j-slf4j-impl/18.0/log4j-slf4j-impl-18.0.jar".into(),
836 sha1: "b".into(),
837 size: 2,
838 url: "http://x".into(),
839 },
840 ];
841 let (cp_args, _) = get_classpath(&vj, &bundle);
842 let cp = &cp_args[1];
843 assert!(cp.contains("log4j-slf4j2-impl"), "should keep slf4j2 binding: {cp}");
844 assert!(!cp.contains("log4j-slf4j-impl-18"), "should drop slf4j1 binding: {cp}");
845 }
846
847 #[test]
848 fn classpath_keeps_both_classifiers_in_same_version_dir() {
849 let vj = bare_version();
850 let bundle = vec![
851 AssetItem::Asset {
852 path: "/loader/forge/libraries/net/minecraftforge/forge/26.1.2-64.0.8/forge-26.1.2-64.0.8-universal.jar".into(),
853 sha1: "a".into(),
854 size: 1,
855 url: "http://x".into(),
856 },
857 AssetItem::Asset {
858 path: "/loader/forge/libraries/net/minecraftforge/forge/26.1.2-64.0.8/forge-26.1.2-64.0.8-client.jar".into(),
859 sha1: "b".into(),
860 size: 2,
861 url: "http://x".into(),
862 },
863 ];
864 let (cp_args, _) = get_classpath(&vj, &bundle);
865 let cp = &cp_args[1];
866 assert!(cp.contains("forge-26.1.2-64.0.8-universal.jar"), "universal must be kept: {cp}");
867 assert!(cp.contains("forge-26.1.2-64.0.8-client.jar"), "client must be kept: {cp}");
868 }
869
870 #[test]
871 fn classpath_separator_is_colon_on_non_windows() {
872 assert_eq!(classpath_separator(), ":");
873 }
874
875 #[test]
878 fn higher_semver_wins() {
879 assert!(version_is_higher("2.0.0", "1.9.9"));
880 assert!(!version_is_higher("1.0.0", "1.0.0"));
881 }
882
883 #[test]
884 fn numeric_dot_split_fallback() {
885 assert!(version_is_higher("1.10.0", "1.9.0"));
886 assert!(!version_is_higher("1.9.0", "1.10.0"));
887 }
888}