Skip to main content

minecraft_java_rs_core/launcher/
mod.rs

1pub mod events;
2pub mod game_data;
3pub mod options;
4
5pub use events::LaunchEvent;
6
7use std::path::PathBuf;
8use std::process::Stdio;
9use std::time::Duration;
10
11use tokio::io::{AsyncBufReadExt, BufReader};
12use tokio::sync::mpsc::Sender;
13
14use crate::error::LaunchError;
15use crate::game::{
16    arguments::{get_classpath, get_game_arguments, get_jvm_arguments, LoaderContext},
17    assets::{copy_assets, get_assets},
18    bundle::{check_bundle, check_files},
19    java::get_java_files,
20    libraries::{extract_natives, get_assets_others, get_libraries},
21    version::get_version_json,
22};
23use crate::launcher::game_data::{load_game_data, save_game_data, GameData, JavaInfo};
24use crate::launcher::options::LaunchOptions;
25use crate::loader::{create_loader, types::LoaderInstallInput};
26use crate::models::loader::LoaderType;
27use crate::models::minecraft::AssetItem;
28use crate::net::check::check_internet;
29use crate::net::downloader::Downloader;
30use crate::utils::version_check::is_old;
31
32// ── Launcher ──────────────────────────────────────────────────────────────────
33
34pub struct Launcher {
35    options: LaunchOptions,
36    game_data: Option<GameData>,
37}
38
39impl Launcher {
40    pub fn new(mut options: LaunchOptions) -> Self {
41        // Absolutize options.path so every path derived from it (classpath,
42        // natives, game args, java binary) works even when the Java process
43        // runs with a different current_dir (e.g. save_dir for Tauri).
44        if options.path.is_relative() {
45            if let Ok(abs) = std::env::current_dir().map(|cwd| cwd.join(&options.path)) {
46                options.path = abs;
47            }
48        }
49        Self { options, game_data: None }
50    }
51
52    pub fn options(&self) -> &LaunchOptions {
53        &self.options
54    }
55
56    pub fn game_data(&self) -> Option<&GameData> {
57        self.game_data.as_ref()
58    }
59
60    /// Download, verify, and optionally install a mod loader for the configured
61    /// Minecraft version. Stores the result in `self.game_data`.
62    ///
63    /// Emits progress events on `event_tx`. After this call,
64    /// [`Launcher::launch`] can be invoked without downloading again.
65    ///
66    /// If there is no internet connection and a valid cache exists, the cache
67    /// is loaded without network access. If there is no cache either, returns
68    /// [`LaunchError::NoInternetNoCache`].
69    pub async fn download_game(
70        &mut self,
71        event_tx: Sender<LaunchEvent>,
72    ) -> Result<(), LaunchError> {
73        let options = &self.options;
74
75        // ── Offline fast-path ─────────────────────────────────────────────────
76        if !check_internet().await {
77            self.game_data = Some(
78                load_game_data(&options.save_dir())
79                    .await
80                    .map_err(|_| LaunchError::NoInternetNoCache)?,
81            );
82            return Ok(());
83        }
84
85        // ── Cache fast-path (skip_bundle_check) ──────────────────────────────
86        // "Skip if possible" hint: load from cache and return early when the
87        // caller trusts the existing installation.  If the cache is absent,
88        // fall through to the full download path without error.
89        if options.skip_bundle_check {
90            if let Ok(cached) = load_game_data(&options.save_dir()).await {
91                self.game_data = Some(cached);
92                let _ = event_tx.send(LaunchEvent::GameDownloadFinished).await;
93                return Ok(());
94            }
95            // Cache absent → continue with normal download.
96        }
97
98        // ── Shared HTTP client ────────────────────────────────────────────────
99        let client = reqwest::Client::builder()
100            .timeout(Duration::from_secs(options.timeout_secs))
101            .build()
102            .map_err(LaunchError::Http)?;
103
104        // ── Version JSON ──────────────────────────────────────────────────────
105        let mut version_json = get_version_json(options, &client).await?;
106        let mc_version = version_json.id.clone();
107
108        // ── File bundle ───────────────────────────────────────────────────────
109        let mut bundle: Vec<AssetItem> = Vec::new();
110        bundle.extend(get_libraries(options, &version_json));
111        bundle.extend(get_assets_others(options, options.url.as_deref(), &client).await?);
112        bundle.extend(get_assets(options, &version_json, &client).await?);
113
114        // Java runtime download is managed separately (has its own concurrency
115        // and progress reporting); its files are not added to the bundle.
116        let java_result = get_java_files(options, &version_json, &client, &event_tx).await?;
117
118        // ── Bundle integrity check & download ─────────────────────────────────
119        let pending = check_bundle(&bundle, &event_tx, options.clamped_verify_concurrency()).await?;
120        if !pending.is_empty() {
121            let downloader = Downloader::new(options.timeout_secs, options.download_concurrency);
122            downloader
123                .download_multiple(pending, event_tx.clone())
124                .await?;
125        }
126
127        // ── Mod loader install ────────────────────────────────────────────────
128        let (loader_libraries, loader_main_class, loader_version_id, loader_type, loader_extra_game_args, loader_extra_jvm_args) = if options.loader.enable {
129            if let Some(loader_type) = &options.loader.loader_type {
130                let mc_jar = options
131                    .path
132                    .join("versions")
133                    .join(&mc_version)
134                    .join(format!("{mc_version}.jar"))
135                    .to_string_lossy()
136                    .into_owned();
137                let mc_json = options
138                    .path
139                    .join("versions")
140                    .join(&mc_version)
141                    .join(format!("{mc_version}.json"))
142                    .to_string_lossy()
143                    .into_owned();
144
145                let input = LoaderInstallInput {
146                    mc_version: mc_version.clone(),
147                    java_path: java_result.java_path.clone(),
148                    mc_jar,
149                    mc_json,
150                };
151
152                let loader_impl = create_loader(loader_type.clone());
153                let result = loader_impl.install(options, &input, &client, &event_tx).await?;
154                (result.libraries, result.main_class, Some(result.loader_version), Some(result.loader_type), result.extra_game_args, result.extra_jvm_args)
155            } else {
156                (vec![], None, None, None, vec![], vec![])
157            }
158        } else {
159            (vec![], None, None, None, vec![], vec![])
160        };
161
162        // ── Download Forge/NeoForge runtime libraries ─────────────────────────
163        // The loader install step (above) downloads processor/install-time JARs
164        // but NOT the runtime classpath libraries listed in version.json (e.g.
165        // bootstraplauncher, securejarhandler, modlauncher).  We check and
166        // download them here.  When --installClient was used the files already
167        // exist and check_bundle returns an empty pending list immediately.
168        if !loader_libraries.is_empty() {
169            let loader_pending = check_bundle(&loader_libraries, &event_tx, options.clamped_verify_concurrency()).await?;
170            if !loader_pending.is_empty() {
171                let downloader =
172                    Downloader::new(options.timeout_secs, options.download_concurrency);
173                downloader
174                    .download_multiple(loader_pending, event_tx.clone())
175                    .await?;
176            }
177        }
178
179        // ── Optional post-download SHA-1 verify ───────────────────────────────
180        if options.verify {
181            check_files(&bundle, &event_tx, options.clamped_verify_concurrency()).await?;
182        }
183
184        // ── Extract native JARs ───────────────────────────────────────────────
185        extract_natives(options, &version_json, &bundle).await?;
186        version_json.has_natives = bundle
187            .iter()
188            .any(|item| matches!(item, AssetItem::NativeAsset { .. }));
189
190        // ── Legacy asset copy (pre-1.6) ───────────────────────────────────────
191        if is_old(version_json.assets.as_deref()) {
192            copy_assets(options, &version_json).await?;
193        }
194
195        // ── Persist & store ───────────────────────────────────────────────────
196        let game_data = GameData {
197            minecraft_json: version_json,
198            minecraft_loader: None,
199            minecraft_version: mc_version,
200            minecraft_java: JavaInfo {
201                files: java_result.files,
202                path: java_result.java_path,
203            },
204            loader_libraries,
205            loader_main_class,
206            loader_version_id,
207            loader_type,
208            loader_extra_game_args,
209            loader_extra_jvm_args,
210        };
211
212        save_game_data(&options.save_dir(), &game_data).await?;
213        self.game_data = Some(game_data);
214
215        let _ = event_tx.send(LaunchEvent::GameDownloadFinished).await;
216
217        Ok(())
218    }
219
220    /// Assemble the Java command line and spawn the Minecraft process.
221    ///
222    /// Resolves game data from `self.game_data` (set by [`Launcher::download_game`])
223    /// or, if absent, from the persisted cache on disk. Returns
224    /// [`LaunchError::GameDataNotReady`] if neither is available.
225    ///
226    /// Stdout and stderr are piped; each line is forwarded as a
227    /// [`LaunchEvent::Data`] event. The caller is responsible for calling
228    /// `child.wait()` and emitting [`LaunchEvent::Close`] when appropriate.
229    pub async fn launch(
230        &self,
231        event_tx: Sender<LaunchEvent>,
232    ) -> Result<tokio::process::Child, LaunchError> {
233        let loaded;
234        let game_data: &GameData = match &self.game_data {
235            Some(gd) => gd,
236            None => {
237                loaded = load_game_data(&self.options.save_dir())
238                    .await
239                    .map_err(|_| LaunchError::GameDataNotReady)?;
240                &loaded
241            }
242        };
243
244        let options = &self.options;
245        let version_json = &game_data.minecraft_json;
246
247        // Natives directory used for -Djava.library.path.
248        let natives_path: PathBuf = options
249            .path
250            .join("versions")
251            .join(&version_json.id)
252            .join("natives");
253
254        // Build the classpath: loader libraries FIRST so Forge/NeoForge classes
255        // take precedence over vanilla when there are collisions.
256        let mut bundle: Vec<AssetItem> = game_data.loader_libraries.clone();
257        let mut vanilla_libs = get_libraries(options, version_json);
258        // Modern Forge (1.17+) and NeoForge use the bootstraplauncher which manages
259        // Minecraft classes via client-slim.jar. Including the full vanilla jar
260        // causes split-package conflicts in the Java module layer.
261        // Old Forge (pre-1.17, no module path args) uses FML's class patcher which
262        // needs the vanilla jar directly on the classpath — don't exclude it there.
263        let uses_module_path = game_data.loader_extra_jvm_args.iter()
264            .any(|a| a == "-p" || a == "--module-path");
265        let exclude_vanilla_jar = matches!(game_data.loader_type, Some(LoaderType::NeoForge))
266            || (matches!(game_data.loader_type, Some(LoaderType::Forge)) && uses_module_path);
267        if exclude_vanilla_jar {
268            let mc_jar = options
269                .path
270                .join("versions")
271                .join(&version_json.id)
272                .join(format!("{}.jar", version_json.id))
273                .to_string_lossy()
274                .into_owned();
275            vanilla_libs.retain(|lib| !matches!(lib, AssetItem::Asset { path, .. } if path == &mc_jar));
276        }
277        bundle.extend(vanilla_libs);
278
279        // Argument assembly.
280        let loader_ctx = game_data.loader_version_id.as_ref().map(|vid| LoaderContext {
281            loader_type: game_data.loader_type.as_ref(),
282            version_id: Some(vid.as_str()),
283            extra_game_args: &game_data.loader_extra_game_args,
284            extra_jvm_args: &game_data.loader_extra_jvm_args,
285        });
286        let jvm_args = get_jvm_arguments(options, version_json, &natives_path, loader_ctx.as_ref());
287        let mut game_args = get_game_arguments(options, version_json, loader_ctx.as_ref());
288        let (cp_args, vanilla_main_class) = get_classpath(version_json, &bundle);
289
290        // Screen size / fullscreen (conditional args from the version JSON are
291        // skipped by get_game_arguments, so we add them here explicitly).
292        if let Some(w) = options.screen.width {
293            game_args.push("--width".into());
294            game_args.push(w.to_string());
295        }
296        if let Some(h) = options.screen.height {
297            game_args.push("--height".into());
298            game_args.push(h.to_string());
299        }
300        if options.screen.fullscreen {
301            game_args.push("--fullscreen".into());
302        }
303
304        let main_class = game_data
305            .loader_main_class
306            .as_deref()
307            .unwrap_or(&vanilla_main_class)
308            .to_owned();
309
310        // Collect JARs on the Java module path (-p flag) from JVM args so we
311        // can exclude them from -cp. NeoForge places bootstrap JARs on the
312        // module path; having them on both paths causes IllegalStateException.
313        let module_path_jars: std::collections::HashSet<String> = {
314            let mut set = std::collections::HashSet::new();
315            let mut iter = jvm_args.iter().peekable();
316            while let Some(arg) = iter.next() {
317                if arg == "-p" {
318                    if let Some(module_path) = iter.next() {
319                        for jar in module_path.split(':') {
320                            // Normalize to a canonical filename for matching.
321                            if let Some(name) = std::path::Path::new(jar).file_name() {
322                                set.insert(name.to_string_lossy().into_owned());
323                            }
324                        }
325                    }
326                }
327            }
328            set
329        };
330
331        // Filter module-path JARs out of -cp to avoid duplicate module errors.
332        let cp_args = if module_path_jars.is_empty() {
333            cp_args
334        } else {
335            cp_args.into_iter().map(|arg| {
336                // The classpath string is the arg after "-cp".
337                if arg.contains(':') || arg.ends_with(".jar") {
338                    let filtered: Vec<&str> = arg.split(':')
339                        .filter(|entry| {
340                            let fname = std::path::Path::new(entry)
341                                .file_name()
342                                .map(|f| f.to_string_lossy().into_owned())
343                                .unwrap_or_default();
344                            !module_path_jars.contains(&fname)
345                        })
346                        .collect();
347                    filtered.join(":")
348                } else {
349                    arg
350                }
351            }).collect()
352        };
353
354        let mut all_args: Vec<String> = Vec::new();
355        all_args.extend(jvm_args);
356        #[cfg(target_os = "linux")]
357        all_args.push("-DGLFW_PLATFORM=x11".into());
358        all_args.extend(cp_args);
359        all_args.push(main_class);
360        all_args.extend(game_args);
361
362        let java_path_raw = &game_data.minecraft_java.path;
363        // Resolve to an absolute path so the binary is found regardless of
364        // what current_dir is set to below.
365        let java_path_buf = std::path::Path::new(java_path_raw)
366            .canonicalize()
367            .unwrap_or_else(|_| std::path::PathBuf::from(java_path_raw));
368        let java_path = java_path_buf.to_string_lossy();
369
370        // Sanitize auth token before logging the command.
371        let access_token = &options.authenticator.access_token;
372        let cmd_str = format!("{} {}", java_path, all_args.join(" "));
373        let sanitized = if access_token.is_empty() {
374            cmd_str
375        } else {
376            cmd_str.replace(access_token.as_str(), "<access_token>")
377        };
378        let _ = event_tx.send(LaunchEvent::Data(sanitized)).await;
379
380        // Spawn the process.
381        let mut cmd = tokio::process::Command::new(java_path.as_ref());
382        cmd.args(&all_args)
383            .current_dir(options.save_dir())
384            .stdout(Stdio::piped())
385            .stderr(Stdio::piped());
386
387        // On Linux, force GLFW 3.4+ to use X11 via XWayland (if available) to
388        // avoid residual [0x1000C] Wayland errors that crash MC's GLFW init check.
389        #[cfg(target_os = "linux")]
390        if std::env::var_os("DISPLAY").is_some() {
391            cmd.env_remove("WAYLAND_DISPLAY");
392            cmd.env("GLFW_PLATFORM", "x11");
393        }
394
395        // LWJGL 2 (old Minecraft, pre-1.13) runs `xrandr` at startup via
396        // Runtime.exec() to enumerate display modes. If xrandr is not installed
397        // the subprocess returns nothing, getScreenNames() returns an empty
398        // array, and LinuxDisplay:951 throws ArrayIndexOutOfBoundsException: 0.
399        // Fix: if xrandr is missing, drop a minimal stub script into a cache
400        // dir and prepend that dir to PATH so Java finds it first.
401        #[cfg(target_os = "linux")]
402        if crate::game::lwjgl_native::uses_lwjgl2(version_json)
403            && !crate::game::lwjgl_native::xrandr_in_path()
404        {
405            let stub_dir = options.path.join("cache").join("xrandr-stub");
406            if crate::game::lwjgl_native::write_xrandr_stub(&stub_dir).await.is_ok() {
407                let base_path = std::env::var("PATH").unwrap_or_default();
408                cmd.env(
409                    "PATH",
410                    format!("{}:{}", stub_dir.to_string_lossy(), base_path),
411                );
412            }
413        }
414
415        let mut child = cmd.spawn()
416            .map_err(|e| LaunchError::ProcessError(e.to_string()))?;
417
418        // Pipe stdout lines → LaunchEvent::Data.
419        if let Some(stdout) = child.stdout.take() {
420            let tx = event_tx.clone();
421            tokio::spawn(async move {
422                let mut lines = BufReader::new(stdout).lines();
423                while let Ok(Some(line)) = lines.next_line().await {
424                    let _ = tx.send(LaunchEvent::Data(line)).await;
425                }
426            });
427        }
428
429        // Pipe stderr lines → LaunchEvent::Data.
430        if let Some(stderr) = child.stderr.take() {
431            let tx = event_tx;
432            tokio::spawn(async move {
433                let mut lines = BufReader::new(stderr).lines();
434                while let Ok(Some(line)) = lines.next_line().await {
435                    let _ = tx.send(LaunchEvent::Data(line)).await;
436                }
437            });
438        }
439
440        Ok(child)
441    }
442
443    /// Download the game and immediately launch it.
444    ///
445    /// Equivalent to `download_game` followed by `launch`. Returns the
446    /// [`tokio::process::Child`] handle so the caller can monitor or kill
447    /// the process.
448    ///
449    /// To receive a [`LaunchEvent::Close`] event, wait on the returned child
450    /// and send it yourself:
451    /// ```ignore
452    /// let code = child.wait().await?.code().unwrap_or(-1);
453    /// let _ = tx.send(LaunchEvent::Close(code)).await;
454    /// ```
455    pub async fn start(
456        &mut self,
457        event_tx: Sender<LaunchEvent>,
458    ) -> Result<tokio::process::Child, LaunchError> {
459        self.download_game(event_tx.clone()).await?;
460        self.launch(event_tx).await
461    }
462
463    /// Heuristically detect whether a game crash was caused by a corrupt or
464    /// incomplete installation.
465    ///
466    /// Returns `true` when `exit_code` is non-zero **and** at least one log
467    /// line matches a known corrupt-installation pattern. The caller can use
468    /// this as a signal to force a full re-check by calling `download_game()`
469    /// with `skip_bundle_check: false` on the next attempt.
470    ///
471    /// # Example
472    /// ```ignore
473    /// let code = child.wait().await?.code().unwrap_or(-1);
474    /// let lines: Vec<String> = /* collected LaunchEvent::Data lines */;
475    /// if Launcher::is_corrupt_crash(code, &lines) {
476    ///     // Re-run download_game with skip_bundle_check: false
477    /// }
478    /// ```
479    pub fn is_corrupt_crash(exit_code: i32, logs: &[String]) -> bool {
480        if exit_code == 0 {
481            return false;
482        }
483        const PATTERNS: &[&str] = &[
484            "Unable to access jarfile",
485            "NoClassDefFoundError",
486            "UnsatisfiedLinkError",
487            "FileNotFoundException",
488            "ClassNotFoundException",
489            "Error: Could not find or load main class",
490            "ZipException",
491            "Error opening zip file",
492        ];
493        logs.iter().any(|line| PATTERNS.iter().any(|pat| line.contains(pat)))
494    }
495}
496
497// ── Tests ─────────────────────────────────────────────────────────────────────
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502    use std::path::PathBuf;
503
504    fn make_options() -> LaunchOptions {
505        use crate::launcher::options::{JavaOptions, LoaderConfig, MemoryConfig, ScreenConfig};
506        use crate::models::minecraft::Authenticator;
507        LaunchOptions {
508            path: PathBuf::from("/mc"),
509            version: "1.20.4".into(),
510            authenticator: Authenticator {
511                access_token: "test-token".into(),
512                name: "Player".into(),
513                uuid: "test-uuid".into(),
514                xbox_account: None,
515                user_properties: None,
516                client_id: None,
517                client_token: None,
518            },
519            timeout_secs: 10,
520            download_concurrency: 5,
521            verify_concurrency: 4,
522            memory: MemoryConfig::default(),
523            java: JavaOptions::default(),
524            loader: LoaderConfig::default(),
525            screen: ScreenConfig::default(),
526            verify: false,
527            game_args: vec![],
528            jvm_args: vec![],
529            instance: None,
530            url: None,
531            mcp: None,
532            intel_enabled_mac: false,
533            bypass_offline: false,
534            skip_bundle_check: false,
535        }
536    }
537
538    #[test]
539    fn launcher_new_stores_options() {
540        let opts = make_options();
541        let launcher = Launcher::new(opts.clone());
542        assert_eq!(launcher.options.version, "1.20.4");
543        assert_eq!(launcher.options.path, PathBuf::from("/mc"));
544    }
545
546    #[test]
547    fn launcher_save_dir_no_instance() {
548        let opts = make_options();
549        let launcher = Launcher::new(opts);
550        assert_eq!(launcher.options.save_dir(), PathBuf::from("/mc"));
551    }
552
553    #[test]
554    fn launcher_save_dir_with_instance() {
555        let mut opts = make_options();
556        opts.instance = Some("myworld".into());
557        let launcher = Launcher::new(opts);
558        assert_eq!(
559            launcher.options.save_dir(),
560            PathBuf::from("/mc/instances/myworld")
561        );
562    }
563
564    #[test]
565    fn sanitize_replaces_access_token() {
566        let token = "secret-access-token";
567        let cmd = format!("java -cp foo.jar Main --accessToken {token}");
568        let sanitized = cmd.replace(token, "<access_token>");
569        assert!(!sanitized.contains(token));
570        assert!(sanitized.contains("<access_token>"));
571    }
572
573    #[test]
574    fn all_args_order_is_correct() {
575        // Verify the expected CLI ordering: jvm_args, -cp, classpath, main_class, game_args
576        let jvm: Vec<String> = vec!["-Xms1G".into(), "-Xmx2G".into()];
577        let cp: Vec<String> = vec!["-cp".into(), "a.jar:b.jar".into()];
578        let main_class = "net.minecraft.client.main.Main".to_owned();
579        let game: Vec<String> = vec!["--username".into(), "Player".into()];
580
581        let mut all: Vec<String> = Vec::new();
582        all.extend(jvm);
583        all.extend(cp);
584        all.push(main_class.clone());
585        all.extend(game);
586
587        assert_eq!(all[0], "-Xms1G");
588        assert_eq!(all[2], "-cp");
589        assert_eq!(all[4], main_class);
590        assert_eq!(all[5], "--username");
591    }
592
593    #[test]
594    fn screen_args_appended_when_set() {
595        use crate::launcher::options::ScreenConfig;
596        let screen = ScreenConfig { width: Some(1920), height: Some(1080), fullscreen: false };
597        let mut game_args: Vec<String> = vec!["--version".into(), "1.20.4".into()];
598        if let Some(w) = screen.width {
599            game_args.push("--width".into());
600            game_args.push(w.to_string());
601        }
602        if let Some(h) = screen.height {
603            game_args.push("--height".into());
604            game_args.push(h.to_string());
605        }
606        assert!(game_args.contains(&"--width".to_string()));
607        assert!(game_args.contains(&"1920".to_string()));
608        assert!(game_args.contains(&"--height".to_string()));
609        assert!(game_args.contains(&"1080".to_string()));
610        assert!(!game_args.contains(&"--fullscreen".to_string()));
611    }
612
613    #[test]
614    fn screen_fullscreen_appended_when_set() {
615        use crate::launcher::options::ScreenConfig;
616        let screen = ScreenConfig { width: None, height: None, fullscreen: true };
617        let mut game_args: Vec<String> = vec![];
618        if screen.fullscreen {
619            game_args.push("--fullscreen".into());
620        }
621        assert!(game_args.contains(&"--fullscreen".to_string()));
622    }
623
624    #[test]
625    fn loader_main_class_overrides_vanilla() {
626        let vanilla = "net.minecraft.client.main.Main".to_owned();
627        let loader_main_class: Option<String> =
628            Some("net.fabricmc.loader.impl.launch.knot.KnotClient".into());
629        let main_class = loader_main_class.as_deref().unwrap_or(&vanilla).to_owned();
630        assert_eq!(main_class, "net.fabricmc.loader.impl.launch.knot.KnotClient");
631    }
632
633    #[test]
634    fn no_loader_main_class_uses_vanilla() {
635        let vanilla = "net.minecraft.client.main.Main".to_owned();
636        let loader_main_class: Option<String> = None;
637        let main_class = loader_main_class.as_deref().unwrap_or(&vanilla).to_owned();
638        assert_eq!(main_class, "net.minecraft.client.main.Main");
639    }
640
641    // ── is_corrupt_crash ─────────────────────────────────────────────────────
642
643    #[test]
644    fn corrupt_crash_zero_exit_always_false() {
645        let logs = vec!["NoClassDefFoundError: net/minecraft/Foo".into()];
646        assert!(!Launcher::is_corrupt_crash(0, &logs));
647    }
648
649    #[test]
650    fn corrupt_crash_nonzero_no_pattern_false() {
651        let logs = vec!["Exception in thread \"main\" java.lang.RuntimeException".into()];
652        assert!(!Launcher::is_corrupt_crash(1, &logs));
653    }
654
655    #[test]
656    fn corrupt_crash_empty_logs_false() {
657        assert!(!Launcher::is_corrupt_crash(1, &[]));
658    }
659
660    #[test]
661    fn corrupt_crash_all_patterns_detected() {
662        let cases = [
663            "Unable to access jarfile foo.jar",
664            "java.lang.NoClassDefFoundError: Foo",
665            "java.lang.UnsatisfiedLinkError: /lib/foo.so",
666            "java.io.FileNotFoundException: /mc/lib.jar (No such file)",
667            "java.lang.ClassNotFoundException: net.minecraft.Main",
668            "Error: Could not find or load main class Main",
669            "java.util.zip.ZipException: invalid LOC header",
670            "Error opening zip file or JAR manifest missing",
671        ];
672        for case in &cases {
673            assert!(
674                Launcher::is_corrupt_crash(1, &[case.to_string()]),
675                "pattern not detected: {case}"
676            );
677        }
678    }
679}