From 4b7812296e794bfcfcef1749e7280452f4fc099e Mon Sep 17 00:00:00 2001 From: rdbtCVS <87152661+rdbtCVS@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:57:26 +0100 Subject: [PATCH] launcher: reach Prism's and MultiMC's shared libraries from an instance folder (#693) A Prism or MultiMC instance keeps no jar of its own: the client sits in the launcher root's shared libraries, two or three folders above `instances/`, and the game is downloaded only once the instance is launched. Choosing `%APPDATA%\PrismLauncher\instances` (or an instance's `minecraft`/`.minecraft` folder) as the Minecraft folder failed Prepare when the jar was not there yet, and the error only listed every launcher without saying what to do. resolve now also looks in the launcher root above an `instances` folder, so a folder deeper than the four walked above the chosen one still finds the libraries. When nothing is found and the chosen folder is inside a launcher Horizon knows, the error names it and says to launch the instance once so it downloads the game. Co-Authored-By: Claude Opus 5.5 --- launcher/src/backend.rs | 2 +- launcher/src/sources.rs | 76 ++++++++++++++++++++++++++++------- launcher/src/sources_tests.rs | 71 +++++++++++++++++++++++++++++++- 3 files changed, 132 insertions(+), 17 deletions(-) diff --git a/launcher/src/backend.rs b/launcher/src/backend.rs index 6082d1ee9..c46315f66 100644 --- a/launcher/src/backend.rs +++ b/launcher/src/backend.rs @@ -792,7 +792,7 @@ pub fn preparer( match sources::resolve(minecraft, &pins, &known, &linked, &fetch, log)? { sources::Resolved::Folder(dir) => dir, sources::Resolved::NotFound(checked) => { - let not_found = sources::not_found(&version, &checked); + let not_found = sources::not_found(&version, &checked, minecraft); download_for_owner(&pins, &own, &fetch, log).map_err(|e| not_found + &e)?; own } diff --git a/launcher/src/sources.rs b/launcher/src/sources.rs index f6a9753ea..864e6bc73 100644 --- a/launcher/src/sources.rs +++ b/launcher/src/sources.rs @@ -207,6 +207,33 @@ pub(crate) fn sha1_file(path: &Path) -> Option { Some(hash.finalize().iter().map(|b| format!("{b:02x}")).collect()) } +/// The Prism or MultiMC launcher root above `dir` (#693). Their instances live in +/// `instances//` (Prism's game folder is `minecraft`, MultiMC's `.minecraft`) and the +/// shared `libraries` and assets sit in the launcher root above `instances`, two or three +/// folders above what players naturally pick. +fn instance_root(dir: &Path) -> Option { + dir.ancestors() + .find(|a| a.file_name() == Some(std::ffi::OsStr::new("instances"))) + .and_then(Path::parent) + .map(Path::to_path_buf) +} + +/// The launcher whose tree `path` is inside, when its folder name is one Horizon knows, so the +/// not-found message can name it (#693). +fn launcher_of(path: &Path) -> Option<&'static str> { + path.ancestors() + .filter_map(Path::file_name) + .filter_map(|name| name.to_str()) + .find_map(|name| match name { + "PrismLauncher" => Some("Prism Launcher"), + "multimc" | "MultiMC" => Some("MultiMC"), + "ModrinthApp" | "com.modrinth.theseus" => Some("the Modrinth App"), + "atlauncher" | "ATLauncher" => Some("ATLauncher"), + "gdlauncher_carbon" | "gdlauncher_next" => Some("GDLauncher"), + _ => None, + }) +} + /// The pinned version in one launcher folder, or what the folder lacks. The folder holding /// `libraries` is the folder itself or its `meta` (Modrinth App), `data` (GDLauncher) or /// `datastore` (GDLauncher Next). @@ -248,19 +275,29 @@ pub fn find(dir: &Path, pins: &Pins) -> Result { } /// The error when no folder has the pinned version: every folder looked in with what it -/// lacks, and what the player can do about it (#419). -pub fn not_found(version: &str, checked: &[(PathBuf, String)]) -> String { +/// lacks, and what the player can do about it (#419, #693). When the chosen folder is inside a +/// launcher Horizon knows, the advice names it: a Prism or MultiMC instance downloads the game +/// only when it is launched once. +pub fn not_found(version: &str, checked: &[(PathBuf, String)], chosen: &Path) -> String { let mut text = format!("Minecraft {version} was not found. Horizon looked in:"); for (dir, missing) in checked { text += &format!("\n {} ({missing})", dir.display()); } - text += &format!( - "\nTo fix it:\n \ - • Play {version} once in the official Minecraft launcher, or in Prism Launcher, MultiMC, \ - the Modrinth App, ATLauncher or GDLauncher, then prepare again.\n \ - • Or choose the folder where your launcher keeps Minecraft {version} (Minecraft folder › \ - Choose… in the launcher, --minecraft on the command line)." - ); + text += "\nTo fix it:"; + text += &match launcher_of(chosen) { + Some(launcher) => format!( + "\n • Launch the {version} instance once in {launcher} so it downloads the game, \ + then prepare again.\n \ + • Or choose the folder {launcher} keeps Minecraft {version} in (Minecraft folder › \ + Choose… in the launcher, --minecraft on the command line)." + ), + None => format!( + "\n • Play {version} once in the official Minecraft launcher, or in Prism Launcher, \ + MultiMC, the Modrinth App, ATLauncher or GDLauncher, then prepare again.\n \ + • Or choose the folder where your launcher keeps Minecraft {version} (Minecraft \ + folder › Choose… in the launcher, --minecraft on the command line)." + ), + }; text } @@ -277,9 +314,9 @@ pub enum Resolved { } /// A Minecraft folder in the official launcher's layout holding the pinned version: the -/// chosen folder when it is one, else the files found in the chosen folder (or a folder above -/// it: a launcher's instance folder may be chosen) or any launcher's default folder, linked into -/// `linked`. +/// chosen folder when it is one, else the files found in the chosen folder, a folder above it +/// (a launcher's instance folder may be chosen, and the launcher root above a Prism or MultiMC +/// `instances` folder is looked in too) or any launcher's default folder, linked into `linked`. pub fn resolve( chosen: &Path, pins: &Pins, @@ -290,13 +327,22 @@ pub fn resolve( ) -> Result { let mut checked = Vec::new(); let mut found = None; - for dir in chosen.ancestors().take(4) { - match find(dir, pins) { + // The chosen folder, the folders above it (an instance's own folder may be chosen), and, + // when it is inside a Prism or MultiMC instance tree, the launcher root above `instances` + // that holds their shared `libraries` (#693). + let mut dirs: Vec = chosen.ancestors().take(4).map(Path::to_path_buf).collect(); + if let Some(root) = instance_root(chosen) { + if !dirs.contains(&root) { + dirs.push(root); + } + } + for dir in dirs { + match find(&dir, pins) { Ok(f) => { found = Some(("the chosen folder", f)); break; } - Err(missing) if dir == chosen => checked.push((dir.to_path_buf(), missing)), + Err(missing) if dir.as_path() == chosen => checked.push((dir, missing)), Err(_) => {} } } diff --git a/launcher/src/sources_tests.rs b/launcher/src/sources_tests.rs index 381d19f27..b273fa4ba 100644 --- a/launcher/src/sources_tests.rs +++ b/launcher/src/sources_tests.rs @@ -90,7 +90,7 @@ fn resolve_with( &|_| {}, )? { Resolved::Folder(dir) => Ok(dir), - Resolved::NotFound(checked) => Err(not_found("26.3", &checked)), + Resolved::NotFound(checked) => Err(not_found("26.3", &checked, chosen)), } } @@ -273,6 +273,75 @@ fn an_instance_folder_may_be_chosen() { assert_linked(&linked, &dir, jar); } +/// Every folder a Prism or MultiMC player naturally picks: the instance list, one instance, an +/// instance's game folder and a folder below it. The shared `libraries` are in the launcher +/// root, which the chosen folder's ancestors do not always reach (#693). +#[test] +fn a_prism_instances_folder_finds_the_launcher_root() { + for (name, chosen) in [ + ("list", "instances"), + ("instance", "instances/26.3"), + ("game", "instances/26.3/minecraft"), + // Deeper than the four folders above it: only the `instances` root helps here. + ("deeper", "instances/26.3/minecraft/saves"), + ] { + let s = Scratch::new(&format!("prism-tree-{name}")); + let jar = "libraries/com/mojang/minecraft/26.3/minecraft-26.3-client.jar"; + let dir = s.launcher("PrismLauncher", "", jar); + s.write( + "PrismLauncher/meta/net.minecraft/26.3.json", + br#"{"mainJar": {"name": "com.mojang:minecraft:26.3:client"}}"#, + ); + fs::create_dir_all(dir.join(chosen)).unwrap(); + let linked = s.0.join("linked"); + let got = resolve_with(&dir.join(chosen), &[], &linked, &Mojang::new(MANIFEST)).unwrap(); + assert_eq!(got, linked, "{chosen}"); + assert_linked(&linked, &dir, jar); + } +} + +#[test] +fn a_multimc_instances_folder_finds_the_launcher_root() { + let s = Scratch::new("multimc-tree"); + let jar = "libraries/com/mojang/minecraft/26.3/minecraft-26.3-client.jar"; + let dir = s.launcher("multimc", "", jar); + s.write( + "multimc/meta/net.minecraft/26.3.json", + br#"{"mainJar": {"name": "com.mojang:minecraft:26.3:client"}}"#, + ); + let chosen = dir.join("instances/26.3/.minecraft"); + fs::create_dir_all(&chosen).unwrap(); + let linked = s.0.join("linked"); + let got = resolve_with(&chosen, &[], &linked, &Mojang::new(MANIFEST)).unwrap(); + assert_eq!(got, linked); + assert_linked(&linked, &dir, jar); +} + +/// When the chosen folder is inside a launcher tree, the error names it and says to launch the +/// instance once, instead of listing every launcher (#693). +#[test] +fn the_not_found_message_names_the_launcher_that_was_chosen() { + let s = Scratch::new("prism-hint"); + let dir = s.0.join("PrismLauncher"); + let chosen = dir.join("instances/26.3/minecraft"); + fs::create_dir_all(&chosen).unwrap(); + let error = + resolve_with(&chosen, &[], &s.0.join("linked"), &Mojang::new(MANIFEST)).unwrap_err(); + assert!( + error.contains("Launch the 26.3 instance once in Prism Launcher"), + "{error}" + ); + assert!(!error.contains("or in Prism Launcher, MultiMC"), "{error}"); + // An unknown folder keeps the advice that names every launcher. + let other = s.0.join("elsewhere"); + fs::create_dir_all(&other).unwrap(); + let error = resolve_with(&other, &[], &s.0.join("linked"), &Mojang::new(MANIFEST)).unwrap_err(); + assert!( + error.contains("Prism Launcher, MultiMC, the Modrinth App, ATLauncher or GDLauncher"), + "{error}" + ); +} + #[test] fn the_known_folders_are_searched_after_the_chosen_one() { let s = Scratch::new("known");