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!(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    // 4. OS-specific flags — only for modern MC that uses `arguments.jvm`.
115    //    Legacy MC (minecraftArguments) ships its own native loader that handles this.
116    if version_json.minecraft_arguments.is_none() {
117        match std::env::consts::OS {
118            // macOS requires first-thread init for OpenGL (LWJGL).
119            "macos" => args.push("-XstartOnFirstThread".into()),
120            // Linux default stack is too small for some MC versions.
121            "linux" => args.push("-Xss1M".into()),
122            // Windows-only heap dump path required by some Mojang driver workarounds.
123            "windows" => args.push(
124                "-XX:HeapDumpPath=MojangTricksIntelDriversForPerformance_javaw.exe_minecraft.exe.heapdump".into()
125            ),
126            _ => {}
127        }
128    }
129
130    // 5. Native library directories.
131    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    // 6. Modern JVM args from the version JSON.
137    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    // 7. Offline bypass: redirect Mojang auth endpoints.
172    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    // 8. User-supplied extra JVM args.
180    for extra in &options.jvm_args {
181        args.push(extra.clone());
182    }
183
184    // 9. Loader JVM args (pre-resolved; e.g. Forge --add-opens, -p module path).
185    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
194/// Build the classpath argument pair and return the main class name.
195///
196/// Returns `(["-cp", "<path1>:<path2>:…"], "net.minecraft.client.main.Main")`.
197///
198/// Library JARs are deduplicated by Maven artifact name: when two JARs share
199/// the same artifact name with different versions, only the highest version is
200/// kept. Insertion order is preserved (loader libs come first when the bundle
201/// is built with loader-first ordering in `launcher/mod.rs`).
202///
203/// If both `log4j-slf4j-impl` and `log4j-slf4j2-impl` are present, the older
204/// SLF4J 1.x binding is removed to prevent duplicate SLF4J binding warnings.
205pub 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    // Resolve SLF4J binding conflict: if the loader ships slf4j2-impl, drop the
226    // vanilla slf4j-impl (1.x) to avoid "multiple SLF4J bindings" warnings/crashes.
227    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            // Keep everything except the SLF4J 1.x binding.
235            !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
250// ── Placeholder helpers ───────────────────────────────────────────────────────
251
252fn 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    // 1.16.x requires the literal string "Xbox" for online play.
267    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    // Loader version id overrides vanilla id so Forge profiles report correctly.
276    let version_name = loader
277        .and_then(|ctx| ctx.version_id)
278        .unwrap_or(version_json.id.as_str())
279        .to_owned();
280
281    // auth_xuid falls back to access_token (matches TS behavior).
282    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    // clientid: clientId → client_token → access_token (matches TS triple fallback).
289    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    // Legacy (pre-1.7.10) assets live under resources/ instead of assets/.
298    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); // legacy alias
311    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()); // legacy alias
315    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
356// ── JVM arg rule evaluation ───────────────────────────────────────────────────
357
358fn 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
406// ── Classpath helpers ─────────────────────────────────────────────────────────
407
408pub fn classpath_separator() -> &'static str {
409    if cfg!(target_os = "windows") {
410        ";"
411    } else {
412        ":"
413    }
414}
415
416/// Keep only the highest-version JAR for each Maven artifact name.
417///
418/// Deduplication key is the artifact directory (grandparent of the JAR file).
419/// Insertion order is preserved: the first time an artifact is seen determines
420/// its position in the output. When a higher-version JAR is found later, only
421/// the path is updated in-place — the position stays with the first insertion.
422/// This ensures loader-first ordering is preserved after deduplication.
423fn deduplicate_classpath(paths: Vec<PathBuf>) -> Vec<PathBuf> {
424    // key → (best_version, best_path)
425    let mut entries: HashMap<String, (String, PathBuf)> = HashMap::new();
426    // Maintains insertion order of keys (first time each key is seen).
427    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            // Include Maven classifier in the key so that e.g. forge-client.jar and
437            // forge-universal.jar (same artifact dir, same version, different classifier)
438            // are treated as distinct entries rather than de-duplicated against each other.
439            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            // Non-Maven path (e.g. client JAR) — use full path as key.
453            (path.to_string_lossy().into_owned(), String::new())
454        };
455
456        if let Some((existing_ver, existing_path)) = entries.get_mut(&artifact_key) {
457            // Already seen — keep whichever version is higher.
458            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    // Dot/dash split numeric fallback (handles "32.1.2-jre", "1.10", etc.).
480    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// ── Tests ─────────────────────────────────────────────────────────────────────
490
491#[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    // ── game args ─────────────────────────────────────────────────────────────
547
548    #[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        // "--username" from vanilla should not be duplicated
641        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(); // no client_id or client_token
669        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    // ── jvm args ──────────────────────────────────────────────────────────────
676
677    #[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    // ── classpath ─────────────────────────────────────────────────────────────
750
751    #[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        // Simulate: loader lib first, vanilla lib second.
803        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    // ── version_is_higher ─────────────────────────────────────────────────────
876
877    #[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}