Skip to main content

minecraft_java_rs_core/game/
arguments.rs

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
8// ── Loader context ────────────────────────────────────────────────────────────
9
10/// Context contributed by the active mod loader for argument building.
11/// Constructed in `launcher/mod.rs` from the stored `GameData` fields.
12pub struct LoaderContext<'a> {
13    /// Which loader was installed (used for Forge-specific JVM flags).
14    pub loader_type: Option<&'a LoaderType>,
15    /// Loader version id (e.g. `"1.20.4-forge-47.4.20"`). Used for `${version_name}`.
16    pub version_id: Option<&'a str>,
17    /// Extra plain-string game args from the loader JSON, merged after vanilla args.
18    pub extra_game_args: &'a [String],
19    /// Extra JVM args from the loader version JSON (`arguments.jvm`), pre-resolved.
20    pub extra_jvm_args: &'a [String],
21}
22
23// ── Public API ────────────────────────────────────────────────────────────────
24
25/// Build vanilla + loader game arguments (everything after the main class).
26///
27/// Handles both the legacy `minecraftArguments` string (pre-1.13) and the
28/// modern `arguments.game` array (1.13+). Conditional entries in the modern
29/// format are skipped. Extra args from `options.game_args` are appended last.
30///
31/// When `loader` is provided:
32/// - `${version_name}` uses the loader version id.
33/// - Loader's own game args (`extra_game_args`) are merged in (deduped).
34pub 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        // Legacy: space-separated string.
44        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                // Conditional entries skipped (added via screen options in launcher/mod.rs).
54            }
55        }
56    }
57
58    // Merge loader's extra game args (e.g. `--launchTarget fmlclient`), deduped.
59    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
75/// Build JVM arguments (everything before the main class, excluding `-cp`).
76///
77/// Sources (in order):
78/// 1. Memory flags `-Xms` / `-Xmx`.
79/// 2. G1GC performance tuning (standard Minecraft launcher defaults).
80/// 3. Forge/NeoForge specific flags when loader is present.
81/// 4. OS-specific flags for modern MC (no `minecraftArguments`).
82/// 5. Native library paths (`-Djava.library.path`, jna, lwjgl, netty).
83/// 6. Modern JVM args from `version_json.arguments.jvm` (skipping `-cp`/`${classpath}`).
84/// 7. Offline-bypass system properties when `options.bypass_offline` is true.
85/// 8. Extra flags from `options.jvm_args`.
86pub 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    // 1. Memory.
96    args.push(format!("-Xms{}", options.memory.min));
97    args.push(format!("-Xmx{}", options.memory.max));
98
99    // 2. G1GC tuning — standard across all major Minecraft launchers.
100    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    // 3. Forge / NeoForge specific flags.
107    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    // 4. OS-specific flags — only for modern MC that uses `arguments.jvm`.
118    //    Legacy MC (minecraftArguments) ships its own native loader that handles this.
119    if version_json.minecraft_arguments.is_none() {
120        match std::env::consts::OS {
121            // macOS requires first-thread init for OpenGL (LWJGL).
122            "macos" => args.push("-XstartOnFirstThread".into()),
123            // Linux default stack is too small for some MC versions.
124            "linux" => args.push("-Xss1M".into()),
125            // Windows-only heap dump path required by some Mojang driver workarounds.
126            "windows" => args.push(
127                "-XX:HeapDumpPath=MojangTricksIntelDriversForPerformance_javaw.exe_minecraft.exe.heapdump".into()
128            ),
129            _ => {}
130        }
131    }
132
133    // 5. Native library directories.
134    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    // 6. Modern JVM args from the version JSON.
142    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    // 7. Offline bypass: redirect Mojang auth endpoints.
180    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    // 8. User-supplied extra JVM args.
188    for extra in &options.jvm_args {
189        args.push(extra.clone());
190    }
191
192    // 9. Loader JVM args (pre-resolved; e.g. Forge --add-opens, -p module path).
193    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
202/// Build the classpath argument pair and return the main class name.
203///
204/// Returns `(["-cp", "<path1>:<path2>:…"], "net.minecraft.client.main.Main")`.
205///
206/// Library JARs are deduplicated by Maven artifact name: when two JARs share
207/// the same artifact name with different versions, only the highest version is
208/// kept. Insertion order is preserved (loader libs come first when the bundle
209/// is built with loader-first ordering in `launcher/mod.rs`).
210///
211/// If both `log4j-slf4j-impl` and `log4j-slf4j2-impl` are present, the older
212/// SLF4J 1.x binding is removed to prevent duplicate SLF4J binding warnings.
213pub 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    // Resolve SLF4J binding conflict: if the loader ships slf4j2-impl, drop the
234    // vanilla slf4j-impl (1.x) to avoid "multiple SLF4J bindings" warnings/crashes.
235    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            // Keep everything except the SLF4J 1.x binding.
245            !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
260// ── Placeholder helpers ───────────────────────────────────────────────────────
261
262fn 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    // 1.16.x requires the literal string "Xbox" for online play.
277    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    // Loader version id overrides vanilla id so Forge profiles report correctly.
286    let version_name = loader
287        .and_then(|ctx| ctx.version_id)
288        .unwrap_or(version_json.id.as_str())
289        .to_owned();
290
291    // auth_xuid falls back to access_token (matches TS behavior).
292    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    // clientid: clientId → client_token → access_token (matches TS triple fallback).
299    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    // Legacy (pre-1.7.10) assets live under resources/ instead of assets/.
308    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); // legacy alias
328    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()); // legacy alias
332    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
374// ── JVM arg rule evaluation ───────────────────────────────────────────────────
375
376fn 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
427// ── Classpath helpers ─────────────────────────────────────────────────────────
428
429pub fn classpath_separator() -> &'static str {
430    if cfg!(target_os = "windows") {
431        ";"
432    } else {
433        ":"
434    }
435}
436
437/// Keep only the highest-version JAR for each Maven artifact name.
438///
439/// Deduplication key is the artifact directory (grandparent of the JAR file).
440/// Insertion order is preserved: the first time an artifact is seen determines
441/// its position in the output. When a higher-version JAR is found later, only
442/// the path is updated in-place — the position stays with the first insertion.
443/// This ensures loader-first ordering is preserved after deduplication.
444fn deduplicate_classpath(paths: Vec<PathBuf>) -> Vec<PathBuf> {
445    // key → (best_version, best_path)
446    let mut entries: HashMap<String, (String, PathBuf)> = HashMap::new();
447    // Maintains insertion order of keys (first time each key is seen).
448    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            // Include Maven classifier in the key so that e.g. forge-client.jar and
458            // forge-universal.jar (same artifact dir, same version, different classifier)
459            // are treated as distinct entries rather than de-duplicated against each other.
460            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            // Non-Maven path (e.g. client JAR) — use full path as key.
474            (path.to_string_lossy().into_owned(), String::new())
475        };
476
477        if let Some((existing_ver, existing_path)) = entries.get_mut(&artifact_key) {
478            // Already seen — keep whichever version is higher.
479            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    // Dot/dash split numeric fallback (handles "32.1.2-jre", "1.10", etc.).
501    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// ── Tests ─────────────────────────────────────────────────────────────────────
511
512#[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    // ── game args ─────────────────────────────────────────────────────────────
573
574    #[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        // "--username" from vanilla should not be duplicated
672        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(); // no client_id or client_token
700        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    // ── jvm args ──────────────────────────────────────────────────────────────
707
708    #[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    // ── classpath ─────────────────────────────────────────────────────────────
785
786    #[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        // Simulate: loader lib first, vanilla lib second.
839        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    // ── version_is_higher ─────────────────────────────────────────────────────
927
928    #[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}