Skip to main content

minecraft_java_rs_core/game/
libraries.rs

1use std::io::Read;
2use std::path::{Path, PathBuf};
3
4use serde::Deserialize;
5
6use crate::error::LaunchError;
7use crate::launcher::options::LaunchOptions;
8use crate::models::minecraft::{ArtifactInfo, AssetItem, Library, MinecraftVersionJson};
9use crate::net::http::fetch_json;
10use crate::utils::paths::get_path_libraries;
11use crate::utils::platform::{mojang_os, skip_library};
12
13// ── Public API ────────────────────────────────────────────────────────────────
14
15/// Build the full list of library/native items to include in the download
16/// bundle.
17///
18/// For each library in `version_json.libraries`:
19/// - **Native** (has a `natives` map): selects the platform-specific
20///   classifier and emits a `NativeAsset`.
21/// - **Regular**: applies Mojang rule evaluation via [`skip_library`]; emits
22///   an `Asset`.  Falls back to deriving the URL from `lib.url` + Maven
23///   coordinate when `downloads.artifact` is absent.
24///
25/// Appends the client JAR and the serialised version JSON (as a `CFile`) at
26/// the end.
27pub fn get_libraries(
28    options: &LaunchOptions,
29    version_json: &MinecraftVersionJson,
30) -> Vec<AssetItem> {
31    let base = &options.path;
32    let current_os = mojang_os();
33    let arch_suffix = arch_suffix_for_natives();
34
35    let mut items: Vec<AssetItem> = Vec::new();
36
37    for lib in &version_json.libraries {
38        if let Some(natives_map) = &lib.natives {
39            // ── Native branch ────────────────────────────────────────────────
40            let native_key = match natives_map.get(current_os) {
41                Some(k) => k.replace("${arch}", arch_suffix),
42                None => continue,
43            };
44
45            let artifact = lib
46                .downloads
47                .as_ref()
48                .and_then(|d| d.classifiers.as_ref())
49                .and_then(|c| c.get(&native_key));
50
51            if let Some(artifact) = artifact {
52                if let Some(item) = artifact_to_item(base, artifact, &lib.name, true) {
53                    items.push(item);
54                }
55            }
56        } else {
57            // ── Regular branch ───────────────────────────────────────────────
58            if skip_library(lib.rules.as_deref().unwrap_or(&[])) {
59                continue;
60            }
61
62            if let Some(item) = resolve_regular_library(base, lib) {
63                items.push(item);
64            }
65        }
66    }
67
68    // Client JAR
69    if let Some(dl) = &version_json.downloads {
70        items.push(AssetItem::Asset {
71            path: base
72                .join("versions")
73                .join(&version_json.id)
74                .join(format!("{}.jar", version_json.id))
75                .to_string_lossy()
76                .into_owned(),
77            sha1: dl.client.sha1.clone(),
78            size: dl.client.size,
79            url: dl.client.url.clone(),
80        });
81    }
82
83    // Version JSON as CFile (written verbatim, no download needed)
84    if let Ok(content) = serde_json::to_string(version_json) {
85        items.push(AssetItem::CFile {
86            path: base
87                .join("versions")
88                .join(&version_json.id)
89                .join(format!("{}.json", version_json.id))
90                .to_string_lossy()
91                .into_owned(),
92            content,
93        });
94    }
95
96    items
97}
98
99/// Fetch a list of custom/additional assets from a remote URL.
100///
101/// The URL is expected to return a JSON array of
102/// `{ path, hash, size, url }` objects.  Each entry is emitted as an
103/// `AssetItem::Asset` with its path prefixed by `options.path` (and
104/// `instances/<instance>/` when instanced).
105///
106/// Returns an empty `Vec` when `url` is `None`.
107pub async fn get_assets_others(
108    options: &LaunchOptions,
109    url: Option<&str>,
110    client: &reqwest::Client,
111) -> Result<Vec<AssetItem>, LaunchError> {
112    let url = match url {
113        Some(u) if !u.is_empty() => u,
114        _ => return Ok(vec![]),
115    };
116
117    let raw: Vec<CustomAssetItem> = fetch_json(client, url)
118        .await
119        .map_err(LaunchError::InvalidData)?;
120
121    let mut items = Vec::with_capacity(raw.len());
122
123    for asset in raw {
124        if asset.path.is_empty() {
125            continue;
126        }
127
128        let full_path = match &options.instance {
129            Some(inst) => options.path.join("instances").join(inst).join(&asset.path),
130            None => options.path.join(&asset.path),
131        };
132
133        items.push(AssetItem::Asset {
134            path: full_path.to_string_lossy().into_owned(),
135            sha1: asset.hash,
136            size: asset.size,
137            url: asset.url,
138        });
139    }
140
141    Ok(items)
142}
143
144/// Base natives directory for a version: `<path>/versions/<id>/natives`.
145///
146/// This is the value `${natives_directory}` expands to in the version JSON's
147/// JVM arguments.
148pub fn natives_base_dir(options: &LaunchOptions, version_json: &MinecraftVersionJson) -> PathBuf {
149    options
150        .path
151        .join("versions")
152        .join(&version_json.id)
153        .join("natives")
154}
155
156/// The directory natives must be extracted to so the JVM can load them.
157///
158/// `-Djava.library.path` is a flat search path, and its value is version-
159/// dependent:
160/// - Minecraft 26.x (LWJGL 3.4) sets it to `${natives_directory}/java` and
161///   reserves sibling dirs (`/lwjgl`, `/jna`, `/netty`) as runtime scratch
162///   space — so the loadable binaries belong in the `java` subdirectory.
163/// - Older versions use `${natives_directory}` itself.
164///
165/// We mirror whatever the version's own `java.library.path` arg resolves to, so
166/// extraction and the JVM agree on one location.
167pub fn natives_dir_for(options: &LaunchOptions, version_json: &MinecraftVersionJson) -> PathBuf {
168    let base = natives_base_dir(options, version_json);
169    match natives_library_subdir(version_json) {
170        Some(sub) => base.join(sub),
171        None => base,
172    }
173}
174
175/// Component appended to `${natives_directory}` by the version's
176/// `-Djava.library.path` JVM argument, if any (e.g. `"java"` for 26.x).
177///
178/// Returns `None` when the version points `java.library.path` straight at
179/// `${natives_directory}` or doesn't specify one.
180fn natives_library_subdir(version_json: &MinecraftVersionJson) -> Option<String> {
181    const PREFIX: &str = "-Djava.library.path=${natives_directory}";
182    let jvm = version_json.arguments.as_ref()?.jvm.as_ref()?;
183    for entry in jvm {
184        // These properties are always plain (unconditional) string entries.
185        let Some(s) = entry.as_str() else { continue };
186        let Some(rest) = s.strip_prefix(PREFIX) else {
187            continue;
188        };
189        let rest = rest.trim_start_matches('/');
190        return if rest.is_empty() {
191            None
192        } else {
193            Some(rest.to_string())
194        };
195    }
196    None
197}
198
199/// Extract all native JARs in `bundle` (`NativeAsset` items) into the directory
200/// that the version's `-Djava.library.path` points to (see
201/// [`natives_dir_for`]).
202///
203/// Skips `META-INF/` entries; sets executable bit on Unix (0o755).
204/// Uses `spawn_blocking` so the synchronous `zip` operations don't block the
205/// Tokio runtime.
206pub async fn extract_natives(
207    options: &LaunchOptions,
208    version_json: &MinecraftVersionJson,
209    bundle: &[AssetItem],
210) -> Result<(), LaunchError> {
211    let native_paths: Vec<PathBuf> = bundle
212        .iter()
213        .filter_map(|item| match item {
214            AssetItem::NativeAsset { path, .. } => Some(PathBuf::from(path)),
215            _ => None,
216        })
217        .collect();
218
219    if native_paths.is_empty() {
220        return Ok(());
221    }
222
223    let natives_dir = natives_dir_for(options, version_json);
224    tokio::fs::create_dir_all(&natives_dir).await?;
225
226    for jar_path in native_paths {
227        let dest = natives_dir.clone();
228        tokio::task::spawn_blocking(move || extract_jar_to_dir(&jar_path, &dest))
229            .await
230            .map_err(|e| LaunchError::Archive(e.to_string()))??;
231    }
232
233    Ok(())
234}
235
236// ── Internal helpers ──────────────────────────────────────────────────────────
237
238/// Arch suffix used in native classifier names (`${arch}` placeholder).
239///
240/// Mojang classifiers use `"32"` / `"64"` for x86 variants; ARM platforms
241/// leave the suffix empty.
242fn arch_suffix_for_natives() -> &'static str {
243    match std::env::consts::ARCH {
244        "x86" => "32",
245        "x86_64" => "64",
246        _ => "",
247    }
248}
249
250/// Convert an `ArtifactInfo` to an `AssetItem`, deriving the relative path
251/// from the Maven coordinate when `artifact.path` is absent.
252fn artifact_to_item(
253    base: &Path,
254    artifact: &ArtifactInfo,
255    lib_name: &str,
256    is_native: bool,
257) -> Option<AssetItem> {
258    let rel = artifact.path.clone().or_else(|| {
259        get_path_libraries(lib_name, None, None)
260            .ok()
261            .map(|lp| lp.path)
262    })?;
263
264    let full_path = base
265        .join("libraries")
266        .join(&rel)
267        .to_string_lossy()
268        .into_owned();
269
270    let sha1 = artifact.sha1.clone().unwrap_or_default();
271    let size = artifact.size.unwrap_or(0);
272    let url = artifact.url.clone();
273
274    if is_native {
275        Some(AssetItem::NativeAsset {
276            path: full_path,
277            sha1,
278            size,
279            url,
280        })
281    } else {
282        Some(AssetItem::Asset {
283            path: full_path,
284            sha1,
285            size,
286            url,
287        })
288    }
289}
290
291/// Resolve a regular (non-native) library to an `AssetItem`.
292///
293/// Priority:
294/// 1. `downloads.artifact` — direct download info from Mojang JSON.
295/// 2. `lib.url` + Maven coordinate — for loader-injected libraries
296///    (Fabric/Quilt) that carry a repository URL but no direct download block.
297fn resolve_regular_library(base: &Path, lib: &Library) -> Option<AssetItem> {
298    // Modern Minecraft (1.19+) encodes natives as separate library entries
299    // with a "natives-<platform>" classifier in the Maven coordinate
300    // (e.g. "org.lwjgl:lwjgl-glfw:3.3.2:natives-linux") instead of the
301    // old-style `lib.natives` map. OS filtering is handled via rules so
302    // by the time we reach here the library already matched the current
303    // platform. Mark it as a native so its contents are extracted to the
304    // natives directory rather than placed on the classpath.
305    let is_native = lib
306        .name
307        .split(':')
308        .nth(3)
309        .map(|c| c.starts_with("natives-"))
310        .unwrap_or(false);
311
312    // Priority 1 — explicit artifact download block
313    if let Some(artifact) = lib.downloads.as_ref().and_then(|d| d.artifact.as_ref()) {
314        return artifact_to_item(base, artifact, &lib.name, is_native);
315    }
316
317    // Priority 2 — build URL from Maven coordinate + repo base URL
318    if let Some(repo) = &lib.url {
319        if let Ok(lp) = get_path_libraries(&lib.name, None, None) {
320            let url = format!("{}/{}", repo.trim_end_matches('/'), lp.path);
321            return Some(AssetItem::Asset {
322                path: base
323                    .join("libraries")
324                    .join(&lp.path)
325                    .to_string_lossy()
326                    .into_owned(),
327                sha1: String::new(),
328                size: 0,
329                url,
330            });
331        }
332    }
333
334    None
335}
336
337/// Extract the native binaries from a JAR/ZIP into `dest`, **flattened**.
338///
339/// `-Djava.library.path` is a flat search path — the JVM does not recurse into
340/// subdirectories — so every native binary must land directly in `dest`. The
341/// jar's internal layout varies by LWJGL version:
342/// - LWJGL ≤ 3.3 puts binaries at the jar root (`liblwjgl.so`).
343/// - LWJGL ≥ 3.4 (Minecraft 26.x) nests them under `<os>/<arch>/…`
344///   (`linux/x64/org/lwjgl/liblwjgl.so`).
345///
346/// Extracting by file name handles both; preserving the path would hide the
347/// 3.4 binaries in a subdirectory and crash with "can't find liblwjgl.so".
348///
349/// Called inside `spawn_blocking`; all I/O is synchronous.
350fn extract_jar_to_dir(jar_path: &Path, dest: &Path) -> Result<(), LaunchError> {
351    let file = std::fs::File::open(jar_path)?;
352    let mut archive =
353        zip::ZipArchive::new(file).map_err(|e| LaunchError::Archive(e.to_string()))?;
354
355    for i in 0..archive.len() {
356        let mut entry = archive
357            .by_index(i)
358            .map_err(|e| LaunchError::Archive(e.to_string()))?;
359
360        let name = entry.name().to_string();
361
362        // META-INF holds the manifest and per-binary `.sha1`/`.git` markers —
363        // none are loadable libraries.
364        if name.starts_with("META-INF") || entry.is_dir() {
365            continue;
366        }
367
368        // Flatten: drop the internal directory structure and key by file name.
369        let file_name = match Path::new(&name).file_name() {
370            Some(f) => f,
371            None => continue,
372        };
373        let out = dest.join(file_name);
374
375        let mut data = Vec::with_capacity(entry.size() as usize);
376        entry.read_to_end(&mut data)?;
377        std::fs::write(&out, &data)?;
378
379        #[cfg(unix)]
380        {
381            use std::os::unix::fs::PermissionsExt;
382            let mut perms = std::fs::metadata(&out)?.permissions();
383            perms.set_mode(0o755);
384            std::fs::set_permissions(&out, perms)?;
385        }
386    }
387
388    Ok(())
389}
390
391// ── Custom asset item (from remote URL) ───────────────────────────────────────
392
393#[derive(Deserialize)]
394struct CustomAssetItem {
395    path: String,
396    hash: String,
397    size: u64,
398    url: String,
399}
400
401// ── Tests ─────────────────────────────────────────────────────────────────────
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406    use std::io::Write;
407    use tempfile::TempDir;
408
409    use crate::launcher::options::{JavaOptions, LoaderConfig, MemoryConfig, ScreenConfig};
410    use crate::models::minecraft::{
411        ArtifactInfo, Authenticator, DownloadArtifact, LibraryDownloads, VersionDownloads,
412    };
413
414    fn opts(path: PathBuf) -> LaunchOptions {
415        LaunchOptions {
416            path,
417            version: "1.20.4".into(),
418            authenticator: Authenticator {
419                access_token: "tok".into(),
420                name: "Player".into(),
421                uuid: "uuid".into(),
422                xbox_account: None,
423                user_properties: None,
424                client_id: None,
425                client_token: None,
426            },
427            timeout_secs: 10,
428            download_concurrency: 5,
429            verify_concurrency: 4,
430            memory: MemoryConfig::default(),
431            java: JavaOptions::default(),
432            loader: LoaderConfig::default(),
433            screen: ScreenConfig::default(),
434            verify: false,
435            game_args: vec![],
436            jvm_args: vec![],
437            instance: None,
438            url: None,
439            mcp: None,
440            intel_enabled_mac: false,
441            bypass_offline: false,
442            skip_bundle_check: false,
443            force_ipv4: false,
444            dns: None,
445        }
446    }
447
448    fn bare_version() -> MinecraftVersionJson {
449        MinecraftVersionJson {
450            id: "1.20.4".into(),
451            version_type: "release".into(),
452            assets: None,
453            asset_index: None,
454            downloads: None,
455            libraries: vec![],
456            arguments: None,
457            minecraft_arguments: None,
458            java_version: None,
459            main_class: None,
460            has_natives: false,
461        }
462    }
463
464    fn artifact(path: &str, url: &str) -> ArtifactInfo {
465        ArtifactInfo {
466            path: Some(path.into()),
467            sha1: Some("aabbcc".into()),
468            size: Some(1024),
469            url: url.into(),
470        }
471    }
472
473    fn lib_with_artifact(name: &str, path: &str, url: &str) -> Library {
474        Library {
475            name: name.into(),
476            rules: None,
477            natives: None,
478            downloads: Some(LibraryDownloads {
479                artifact: Some(artifact(path, url)),
480                classifiers: None,
481            }),
482            url: None,
483            loader: None,
484        }
485    }
486
487    // ── get_libraries ─────────────────────────────────────────────────────────
488
489    #[test]
490    fn includes_client_jar_when_downloads_present() {
491        let dir = TempDir::new().unwrap();
492        let mut vj = bare_version();
493        vj.downloads = Some(VersionDownloads {
494            client: DownloadArtifact {
495                sha1: "abc".into(),
496                size: 42,
497                url: "https://example.com/client.jar".into(),
498            },
499            server: None,
500            client_mappings: None,
501            server_mappings: None,
502        });
503
504        let items = get_libraries(&opts(dir.path().to_path_buf()), &vj);
505        assert!(items
506            .iter()
507            .any(|i| matches!(i, AssetItem::Asset { path, .. } if path.ends_with("1.20.4.jar"))));
508    }
509
510    #[test]
511    fn includes_version_json_as_cfile() {
512        let dir = TempDir::new().unwrap();
513        let vj = bare_version();
514        let items = get_libraries(&opts(dir.path().to_path_buf()), &vj);
515        assert!(items
516            .iter()
517            .any(|i| matches!(i, AssetItem::CFile { path, .. } if path.ends_with("1.20.4.json"))));
518    }
519
520    #[test]
521    fn regular_library_becomes_asset() {
522        let dir = TempDir::new().unwrap();
523        let mut vj = bare_version();
524        vj.libraries = vec![lib_with_artifact(
525            "com.example:lib:1.0",
526            "com/example/lib/1.0/lib-1.0.jar",
527            "https://example.com/lib.jar",
528        )];
529
530        let items = get_libraries(&opts(dir.path().to_path_buf()), &vj);
531        assert!(items.iter().any(
532            |i| matches!(i, AssetItem::Asset { url, .. } if url == "https://example.com/lib.jar")
533        ));
534    }
535
536    #[test]
537    fn native_library_becomes_native_asset() {
538        let dir = TempDir::new().unwrap();
539        let mut vj = bare_version();
540
541        let current_os = mojang_os();
542        let classifier_key = format!("natives-{current_os}");
543
544        let mut classifiers = std::collections::HashMap::new();
545        classifiers.insert(
546            classifier_key.clone(),
547            artifact(
548                &format!("org/lwjgl/lwjgl/{classifier_key}/lwjgl-native.jar"),
549                "https://example.com/native.jar",
550            ),
551        );
552
553        let mut natives_map = std::collections::HashMap::new();
554        natives_map.insert(current_os.to_string(), classifier_key);
555
556        vj.libraries = vec![Library {
557            name: "org.lwjgl:lwjgl:3.3.1".into(),
558            rules: None,
559            natives: Some(natives_map),
560            downloads: Some(LibraryDownloads {
561                artifact: None,
562                classifiers: Some(classifiers),
563            }),
564            url: None,
565            loader: None,
566        }];
567
568        let items = get_libraries(&opts(dir.path().to_path_buf()), &vj);
569        assert!(items.iter().any(|i| matches!(i, AssetItem::NativeAsset { url, .. } if url == "https://example.com/native.jar")));
570    }
571
572    #[test]
573    fn modern_native_classifier_in_name_becomes_native_asset() {
574        // 1.19+ LWJGL natives: no `lib.natives` map; classifier is in the name.
575        let dir = TempDir::new().unwrap();
576        let mut vj = bare_version();
577
578        let current_os = mojang_os();
579        let classifier = format!("natives-{current_os}");
580        let lib_name = format!("org.lwjgl:lwjgl-glfw:3.3.2:{classifier}");
581        let jar_path = format!("org/lwjgl/lwjgl-glfw/3.3.2/lwjgl-glfw-3.3.2-{classifier}.jar");
582
583        vj.libraries = vec![Library {
584            name: lib_name,
585            rules: None,
586            natives: None,
587            downloads: Some(LibraryDownloads {
588                artifact: Some(artifact(
589                    &jar_path,
590                    "https://libraries.minecraft.net/native.jar",
591                )),
592                classifiers: None,
593            }),
594            url: None,
595            loader: None,
596        }];
597
598        let items = get_libraries(&opts(dir.path().to_path_buf()), &vj);
599        assert!(
600            items
601                .iter()
602                .any(|i| matches!(i, AssetItem::NativeAsset { .. })),
603            "expected NativeAsset for modern natives-<os> classifier, got: {items:?}"
604        );
605    }
606
607    #[test]
608    fn library_with_url_fallback_builds_url() {
609        let dir = TempDir::new().unwrap();
610        let mut vj = bare_version();
611        vj.libraries = vec![Library {
612            name: "net.fabricmc:fabric-loader:0.15.0".into(),
613            rules: None,
614            natives: None,
615            downloads: None,
616            url: Some("https://maven.fabricmc.net".into()),
617            loader: None,
618        }];
619
620        let items = get_libraries(&opts(dir.path().to_path_buf()), &vj);
621        assert!(items.iter().any(|i| match i {
622            AssetItem::Asset { url, .. } => url.starts_with("https://maven.fabricmc.net"),
623            _ => false,
624        }));
625    }
626
627    // ── get_assets_others ─────────────────────────────────────────────────────
628
629    #[tokio::test]
630    async fn get_assets_others_none_url_returns_empty() {
631        let dir = TempDir::new().unwrap();
632        let client = reqwest::Client::new();
633        let result = get_assets_others(&opts(dir.path().to_path_buf()), None, &client)
634            .await
635            .unwrap();
636        assert!(result.is_empty());
637    }
638
639    #[tokio::test]
640    async fn get_assets_others_empty_string_returns_empty() {
641        let dir = TempDir::new().unwrap();
642        let client = reqwest::Client::new();
643        let result = get_assets_others(&opts(dir.path().to_path_buf()), Some(""), &client)
644            .await
645            .unwrap();
646        assert!(result.is_empty());
647    }
648
649    // ── extract_natives ───────────────────────────────────────────────────────
650
651    #[tokio::test]
652    async fn extract_natives_noop_with_empty_bundle() {
653        let dir = TempDir::new().unwrap();
654        let vj = bare_version();
655        extract_natives(&opts(dir.path().to_path_buf()), &vj, &[])
656            .await
657            .unwrap();
658        assert!(!dir.path().join("versions").exists());
659    }
660
661    #[tokio::test]
662    async fn extract_natives_extracts_to_natives_dir() {
663        // Build a tiny ZIP with one native file and one META-INF entry.
664        let dir = TempDir::new().unwrap();
665        let jar_path = dir.path().join("native.jar");
666
667        {
668            use zip::write::SimpleFileOptions;
669            let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
670            let opts_zip = SimpleFileOptions::default();
671
672            w.start_file("META-INF/MANIFEST.MF", opts_zip).unwrap();
673            w.write_all(b"Manifest-Version: 1.0\n").unwrap();
674
675            // META-INF marker files must never be extracted.
676            w.start_file("META-INF/linux/x64/org/lwjgl/liblwjgl.so.sha1", opts_zip)
677                .unwrap();
678            w.write_all(b"deadbeef").unwrap();
679
680            // LWJGL ≤ 3.3 layout: binary at the jar root.
681            w.start_file("liblwjgl.so", opts_zip).unwrap();
682            w.write_all(b"ELF root native").unwrap();
683
684            // LWJGL ≥ 3.4 layout: binary nested under <os>/<arch>/… — must be
685            // flattened into the natives dir, not buried in subdirectories.
686            w.start_file("linux/x64/org/lwjgl/liblwjgl_opengl.so", opts_zip)
687                .unwrap();
688            w.write_all(b"ELF nested native").unwrap();
689
690            let finished = w.finish().unwrap();
691            std::fs::write(&jar_path, finished.get_ref()).unwrap();
692        }
693
694        let vj = bare_version();
695        let options = opts(dir.path().to_path_buf());
696
697        let bundle = vec![AssetItem::NativeAsset {
698            path: jar_path.to_string_lossy().into_owned(),
699            sha1: String::new(),
700            size: 0,
701            url: String::new(),
702        }];
703
704        extract_natives(&options, &vj, &bundle).await.unwrap();
705
706        let natives_dir = dir.path().join("versions").join("1.20.4").join("natives");
707
708        // Root-level binary lands directly in the natives dir.
709        assert_eq!(
710            std::fs::read(natives_dir.join("liblwjgl.so")).unwrap(),
711            b"ELF root native"
712        );
713        // Nested binary is flattened to the natives dir root (the fix).
714        assert_eq!(
715            std::fs::read(natives_dir.join("liblwjgl_opengl.so")).unwrap(),
716            b"ELF nested native"
717        );
718        // No directory structure or META-INF leaks through.
719        assert!(!natives_dir.join("linux").exists());
720        assert!(!natives_dir.join("META-INF").exists());
721        assert!(!natives_dir.join("liblwjgl.so.sha1").exists());
722    }
723
724    /// Build a version whose `arguments.jvm` mirrors Minecraft 26.x: it points
725    /// `java.library.path` at the `${natives_directory}/java` subdirectory.
726    fn version_with_java_subdir(id: &str) -> MinecraftVersionJson {
727        let mut vj = bare_version();
728        vj.id = id.to_string();
729        vj.arguments = Some(crate::models::minecraft::Arguments {
730            game: None,
731            jvm: Some(vec![
732                serde_json::Value::String("--enable-native-access=ALL-UNNAMED".into()),
733                serde_json::Value::String("-Djava.library.path=${natives_directory}/java".into()),
734                serde_json::Value::String(
735                    "-Dorg.lwjgl.system.SharedLibraryExtractPath=${natives_directory}/lwjgl".into(),
736                ),
737            ]),
738        });
739        vj
740    }
741
742    #[test]
743    fn natives_subdir_detected_for_modern_scheme() {
744        let vj = version_with_java_subdir("26.2");
745        assert_eq!(natives_library_subdir(&vj), Some("java".to_string()));
746        // Legacy versions (no arguments.jvm) extract straight to the root.
747        assert_eq!(natives_library_subdir(&bare_version()), None);
748
749        let options = opts(PathBuf::from("/tmp/mc"));
750        let expected = options
751            .path
752            .join("versions")
753            .join("26.2")
754            .join("natives")
755            .join("java");
756        assert_eq!(natives_dir_for(&options, &vj), expected);
757    }
758
759    #[tokio::test]
760    async fn extract_natives_targets_java_subdir_on_modern_versions() {
761        // Regression: Minecraft 26.x sets `java.library.path=${natives_directory}/java`,
762        // so liblwjgl.so must land in `natives/java/`, not `natives/`. Extracting
763        // to the root makes the JVM crash with "Failed to locate library: liblwjgl.so".
764        let dir = TempDir::new().unwrap();
765        let jar_path = dir.path().join("native.jar");
766        {
767            use zip::write::SimpleFileOptions;
768            let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
769            let o = SimpleFileOptions::default();
770            w.start_file("linux/x64/org/lwjgl/liblwjgl.so", o).unwrap();
771            w.write_all(b"ELF").unwrap();
772            let finished = w.finish().unwrap();
773            std::fs::write(&jar_path, finished.get_ref()).unwrap();
774        }
775
776        let vj = version_with_java_subdir("26.2");
777        let options = opts(dir.path().to_path_buf());
778        let bundle = vec![AssetItem::NativeAsset {
779            path: jar_path.to_string_lossy().into_owned(),
780            sha1: String::new(),
781            size: 0,
782            url: String::new(),
783        }];
784
785        extract_natives(&options, &vj, &bundle).await.unwrap();
786
787        let natives_root = dir.path().join("versions").join("26.2").join("natives");
788        assert!(
789            natives_root.join("java").join("liblwjgl.so").exists(),
790            "liblwjgl.so must be in the java.library.path subdir"
791        );
792        assert!(
793            !natives_root.join("liblwjgl.so").exists(),
794            "must not be left at the natives root for modern versions"
795        );
796    }
797}