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