From 260eaa6463f8f34eda9555ef448b4d07e7c53264 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:39:03 +0100 Subject: [PATCH 1/4] Generate different Daedalus Quilt templates for 26.x/non-26.x --- apps/daedalus_client/src/fabric.rs | 409 ++++++++++++++++++++++------- 1 file changed, 316 insertions(+), 93 deletions(-) diff --git a/apps/daedalus_client/src/fabric.rs b/apps/daedalus_client/src/fabric.rs index 3d6f1eeb0c..156794ea59 100644 --- a/apps/daedalus_client/src/fabric.rs +++ b/apps/daedalus_client/src/fabric.rs @@ -64,7 +64,92 @@ async fn fetch( &semaphore, ) .await?; + let all_loader_versions = fabric_manifest.loader.clone(); + let all_game_versions = fabric_manifest.game.clone(); + let metadata_groups = metadata_groups(mod_loader, &all_game_versions); + if metadata_groups + .iter() + .any(|group| group.id != UNIVERSAL_METADATA_GROUP) + { + let loaders = all_loader_versions + .iter() + .filter(|x| !skip_versions.contains(&&*x.version)) + .collect::>(); + + let profile_requests = metadata_groups + .iter() + .flat_map(|group| { + loaders.iter().map(move |loader| ProfileRequest { + group: group.id.to_string(), + template_game_version: group.template_game_version.clone(), + loader_version: loader.version.clone(), + url: format!( + "{}/versions/loader/{}/{}/profile/json", + meta_url, group.template_game_version, loader.version + ), + }) + }) + .collect::>(); + + fetch_metadata_profiles( + mod_loader, + format_version, + maven_url, + profile_requests, + &upload_files, + &mirror_artifacts, + &semaphore, + ) + .await?; + + let manifest = daedalus::modded::Manifest { + game_versions: all_game_versions + .into_iter() + .map(|game_version| { + let group = metadata_group_id_for_game_version( + mod_loader, + &game_version.version, + ); + + daedalus::modded::Version { + id: game_version.version.clone(), + stable: game_version.stable, + loaders: loaders + .iter() + .map(|loader| { + let version_path = metadata_version_path( + mod_loader, + format_version, + &loader.version, + group, + ); + + daedalus::modded::LoaderVersion { + id: loader.version.clone(), + url: format_url(&version_path), + stable: loader.stable, + } + }) + .collect(), + } + }) + .collect(), + }; + + upload_files.insert( + format!("{mod_loader}/v{format_version}/manifest.json"), + UploadFile { + file: bytes::Bytes::from(serde_json::to_vec(&manifest)?), + content_type: Some("application/json".to_string()), + }, + ); + + return Ok(FetchResult { + upload_files, + mirror_artifacts, + }); + } // We check Modrinth's manifest to find newly added loader versions, // intermediary/mapping artifacts, and game versions. let ( @@ -125,8 +210,6 @@ async fn fetch( ) }; - const DUMMY_GAME_VERSION: &str = "1.21"; - if !fetch_intermediary_versions.is_empty() { for x in &fetch_intermediary_versions { insert_mirrored_artifact( @@ -140,94 +223,37 @@ async fn fetch( } if !fetch_fabric_versions.is_empty() { - let fabric_version_manifest_urls = fetch_fabric_versions + let universal_group = metadata_groups .iter() - .map(|x| { - format!( + .find(|group| group.id == UNIVERSAL_METADATA_GROUP) + .expect("fabric metadata should have a universal group"); + let profile_requests = fetch_fabric_versions + .iter() + .map(|loader| ProfileRequest { + group: universal_group.id.to_string(), + template_game_version: universal_group + .template_game_version + .clone(), + loader_version: loader.version.clone(), + url: format!( "{}/versions/loader/{}/{}/profile/json", - meta_url, DUMMY_GAME_VERSION, x.version - ) + meta_url, + universal_group.template_game_version, + loader.version + ), }) .collect::>(); - let fabric_version_manifests = futures::future::try_join_all( - fabric_version_manifest_urls - .iter() - .map(|x| download_file(x, None, &semaphore)), - ) - .await? - .into_iter() - .map(|x| serde_json::from_slice(&x)) - .collect::, serde_json::Error>>()?; - - let patched_version_manifests = fabric_version_manifests - .into_iter() - .map(|mut version_info| { - for lib in &mut version_info.libraries { - let new_name = lib - .name - .replace(DUMMY_GAME_VERSION, DUMMY_REPLACE_STRING); - - // Hard-code: This library is not present on fabric's maven, so we fetch it from MC libraries - if &*lib.name == "net.minecraft:launchwrapper:1.12" { - lib.url = Some( - "https://libraries.minecraft.net/".to_string(), - ); - } - - // If a library is not intermediary, we add it to mirror artifacts to be mirrored - if lib.name == new_name { - insert_mirrored_artifact( - &new_name, - None, - vec![ - lib.url - .clone() - .unwrap_or_else(|| maven_url.to_string()), - ], - false, - &mirror_artifacts, - )?; - } else { - lib.name = new_name; - } - - lib.url = Some(format_url("maven/")); - } - - version_info.id = version_info - .id - .replace(DUMMY_GAME_VERSION, DUMMY_REPLACE_STRING); - version_info.inherits_from = version_info - .inherits_from - .replace(DUMMY_GAME_VERSION, DUMMY_REPLACE_STRING); - - Ok(version_info) - }) - .collect::, Error>>()?; - let serialized_version_manifests = patched_version_manifests - .iter() - .map(|x| serde_json::to_vec(x).map(bytes::Bytes::from)) - .collect::, serde_json::Error>>()?; - - serialized_version_manifests - .into_iter() - .enumerate() - .for_each(|(index, bytes)| { - let loader = fetch_fabric_versions[index]; - let version_path = format!( - "{mod_loader}/v{format_version}/versions/{}.json", - loader.version - ); - - upload_files.insert( - version_path, - UploadFile { - file: bytes, - content_type: Some("application/json".to_string()), - }, - ); - }); + fetch_metadata_profiles( + mod_loader, + format_version, + maven_url, + profile_requests, + &upload_files, + &mirror_artifacts, + &semaphore, + ) + .await?; } if !fetch_fabric_versions.is_empty() @@ -240,17 +266,19 @@ async fn fetch( let loader_versions = daedalus::modded::Version { id: DUMMY_REPLACE_STRING.to_string(), stable: true, - loaders: fabric_manifest - .loader - .into_iter() + loaders: all_loader_versions + .iter() + .filter(|x| !skip_versions.contains(&&*x.version)) .map(|x| { - let version_path = format!( - "{mod_loader}/v{format_version}/versions/{}.json", - x.version, + let version_path = metadata_version_path( + mod_loader, + format_version, + &x.version, + UNIVERSAL_METADATA_GROUP, ); daedalus::modded::LoaderVersion { - id: x.version, + id: x.version.clone(), url: format_url(&version_path), stable: x.stable, } @@ -260,7 +288,7 @@ async fn fetch( let manifest = daedalus::modded::Manifest { game_versions: std::iter::once(loader_versions) - .chain(fabric_manifest.game.into_iter().map(|x| { + .chain(all_game_versions.into_iter().map(|x| { daedalus::modded::Version { id: x.version, stable: x.stable, @@ -285,6 +313,201 @@ async fn fetch( }) } +struct MetadataGroup { + id: &'static str, + template_game_version: String, +} + +const UNIVERSAL_METADATA_GROUP: &str = "universal"; + +struct ProfileRequest { + group: String, + template_game_version: String, + loader_version: String, + url: String, +} + +fn metadata_groups( + mod_loader: &str, + game_versions: &[FabricGameVersion], +) -> Vec { + if mod_loader != "quilt" { + return vec![MetadataGroup { + id: UNIVERSAL_METADATA_GROUP, + template_game_version: "1.21".to_string(), + }]; + } + + let pre_26_game_versions = game_versions + .iter() + .filter(|x| { + metadata_group_id_for_game_version(mod_loader, &x.version) + == "pre-26-x" + }) + .cloned() + .collect::>(); + let game_versions_26 = game_versions + .iter() + .filter(|x| { + metadata_group_id_for_game_version(mod_loader, &x.version) == "26-x" + }) + .cloned() + .collect::>(); + + let mut groups = Vec::new(); + + if !pre_26_game_versions.is_empty() { + groups.push(MetadataGroup { + id: "pre-26-x", + template_game_version: pre_26_game_versions + .iter() + .find(|x| x.version == "1.21") + .unwrap_or(&pre_26_game_versions[0]) + .version + .clone(), + }); + } + + if !game_versions_26.is_empty() { + groups.push(MetadataGroup { + id: "26-x", + template_game_version: game_versions_26[0].version.clone(), + }); + } + + groups +} + +fn metadata_group_id_for_game_version( + mod_loader: &str, + game_version: &str, +) -> &'static str { + if mod_loader == "quilt" + && (game_version.starts_with("26.") || game_version.starts_with("26w")) + { + "26-x" + } else if mod_loader == "quilt" { + "pre-26-x" + } else { + UNIVERSAL_METADATA_GROUP + } +} + +fn metadata_version_path( + mod_loader: &str, + format_version: usize, + loader_version: &str, + group: &str, +) -> String { + if group == UNIVERSAL_METADATA_GROUP { + format!("{mod_loader}/v{format_version}/versions/{loader_version}.json") + } else { + format!( + "{mod_loader}/v{format_version}/versions/{loader_version}/{group}.json" + ) + } +} + +async fn fetch_metadata_profiles( + mod_loader: &str, + format_version: usize, + maven_url: &str, + profile_requests: Vec, + upload_files: &DashMap, + mirror_artifacts: &DashMap, + semaphore: &Arc, +) -> Result<(), Error> { + let version_manifests = futures::future::try_join_all( + profile_requests + .iter() + .map(|x| download_file(&x.url, None, semaphore)), + ) + .await? + .into_iter() + .map(|x| serde_json::from_slice(&x)) + .collect::, serde_json::Error>>()?; + + let patched_version_manifests = version_manifests + .into_iter() + .zip(profile_requests.iter()) + .map(|(mut version_info, request)| { + patch_version_info( + &mut version_info, + &request.template_game_version, + maven_url, + mirror_artifacts, + )?; + + Ok(version_info) + }) + .collect::, Error>>()?; + let serialized_version_manifests = patched_version_manifests + .iter() + .map(|x| serde_json::to_vec(x).map(bytes::Bytes::from)) + .collect::, serde_json::Error>>()?; + + serialized_version_manifests + .into_iter() + .zip(profile_requests) + .for_each(|(bytes, request)| { + let version_path = metadata_version_path( + mod_loader, + format_version, + &request.loader_version, + &request.group, + ); + + upload_files.insert( + version_path, + UploadFile { + file: bytes, + content_type: Some("application/json".to_string()), + }, + ); + }); + + Ok(()) +} + +fn patch_version_info( + version_info: &mut PartialVersionInfo, + game_version: &str, + maven_url: &str, + mirror_artifacts: &DashMap, +) -> Result<(), Error> { + for lib in &mut version_info.libraries { + let new_name = lib.name.replace(game_version, DUMMY_REPLACE_STRING); + + // Hard-code: This library is not present on fabric's maven, so we fetch it from MC libraries + if &*lib.name == "net.minecraft:launchwrapper:1.12" { + lib.url = Some("https://libraries.minecraft.net/".to_string()); + } + + // If a library is not intermediary, we add it to mirror artifacts to be mirrored + if lib.name == new_name { + insert_mirrored_artifact( + &new_name, + None, + vec![lib.url.clone().unwrap_or_else(|| maven_url.to_string())], + false, + mirror_artifacts, + )?; + } else { + lib.name = new_name; + } + + lib.url = Some(format_url("maven/")); + } + + version_info.id = + version_info.id.replace(game_version, DUMMY_REPLACE_STRING); + version_info.inherits_from = version_info + .inherits_from + .replace(game_version, DUMMY_REPLACE_STRING); + + Ok(()) +} + #[derive(Deserialize, Debug, Clone)] struct FabricVersions { pub loader: Vec, From d64aa08a914561a518ba810c8a292fba81cd86ae Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:44:41 +0100 Subject: [PATCH 2/4] add docs --- apps/daedalus_client/src/fabric.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/daedalus_client/src/fabric.rs b/apps/daedalus_client/src/fabric.rs index 156794ea59..1df6ad8226 100644 --- a/apps/daedalus_client/src/fabric.rs +++ b/apps/daedalus_client/src/fabric.rs @@ -1,3 +1,21 @@ +//! Fetches Fabric-compatible loader metadata. +//! +//! Fabric and Quilt both expose loader profiles for a concrete Minecraft +//! version, but Daedalus publishes templated profiles using +//! `${modrinth.gameVersion}`. A group is a set of Minecraft versions whose +//! upstream loader profiles have the same structure after the concrete +//! Minecraft version is replaced with `${modrinth.gameVersion}`. Fabric uses +//! one universal group, so its public profile paths stay as +//! `versions/{loader}.json`. Quilt has more than one group: versions before +//! 26.x include hashed/intermediary libraries, while 26.x versions do not. For +//! Quilt, Daedalus writes one templated profile per group at +//! `versions/{loader}/{group}.json`. +//! +//! The Quilt top-level manifest intentionally remains backwards-compatible +//! with old app builds: it still lists concrete Minecraft version IDs and the +//! full loader list on each row. Only the loader profile URL points at the +//! appropriate group, such as `pre-26-x` or `26-x`. + use crate::util::{download_file, fetch_json, format_url}; use crate::{ Error, FetchResult, MirrorArtifact, UploadFile, insert_mirrored_artifact, From ca274f743115ae2335964b0f7ab661096fde371b Mon Sep 17 00:00:00 2001 From: aecsocket Date: Wed, 1 Jul 2026 18:55:48 +0100 Subject: [PATCH 3/4] wip: daedalus version groups --- apps/daedalus_client/src/fabric.rs | 161 +++++++------------- apps/daedalus_client/src/forge.rs | 28 ++-- apps/daedalus_client/src/main.rs | 1 + apps/daedalus_client/src/metadata_groups.rs | 132 ++++++++++++++++ packages/daedalus/src/modded.rs | 19 ++- 5 files changed, 220 insertions(+), 121 deletions(-) create mode 100644 apps/daedalus_client/src/metadata_groups.rs diff --git a/apps/daedalus_client/src/fabric.rs b/apps/daedalus_client/src/fabric.rs index 1df6ad8226..dbb7fa66dc 100644 --- a/apps/daedalus_client/src/fabric.rs +++ b/apps/daedalus_client/src/fabric.rs @@ -9,13 +9,11 @@ //! `versions/{loader}.json`. Quilt has more than one group: versions before //! 26.x include hashed/intermediary libraries, while 26.x versions do not. For //! Quilt, Daedalus writes one templated profile per group at -//! `versions/{loader}/{group}.json`. -//! -//! The Quilt top-level manifest intentionally remains backwards-compatible -//! with old app builds: it still lists concrete Minecraft version IDs and the -//! full loader list on each row. Only the loader profile URL points at the -//! appropriate group, such as `pre-26-x` or `26-x`. +//! `version-group/{group}/loader-version/{loader}`. +use crate::metadata_groups::{ + UNIVERSAL_METADATA_GROUP, metadata_group_for_game_version, metadata_groups, +}; use crate::util::{download_file, fetch_json, format_url}; use crate::{ Error, FetchResult, MirrorArtifact, UploadFile, insert_mirrored_artifact, @@ -84,7 +82,10 @@ async fn fetch( .await?; let all_loader_versions = fabric_manifest.loader.clone(); let all_game_versions = fabric_manifest.game.clone(); - let metadata_groups = metadata_groups(mod_loader, &all_game_versions); + let metadata_groups = metadata_groups( + mod_loader, + all_game_versions.iter().map(|x| x.version.as_str()), + ); if metadata_groups .iter() @@ -100,11 +101,15 @@ async fn fetch( .flat_map(|group| { loaders.iter().map(move |loader| ProfileRequest { group: group.id.to_string(), - template_game_version: group.template_game_version.clone(), + loader_profile_template_game_version: group + .loader_profile_template_game_version + .clone(), loader_version: loader.version.clone(), url: format!( "{}/versions/loader/{}/{}/profile/json", - meta_url, group.template_game_version, loader.version + meta_url, + group.loader_profile_template_game_version, + loader.version ), }) }) @@ -121,38 +126,50 @@ async fn fetch( ) .await?; + let version_groups = metadata_groups + .iter() + .map(|group| daedalus::modded::VersionGroup { + id: group.id.to_string(), + loaders: loaders + .iter() + .map(|loader| { + let version_path = metadata_version_path( + mod_loader, + format_version, + &loader.version, + group.id, + ); + + daedalus::modded::LoaderVersion { + id: loader.version.clone(), + url: format_url(&version_path), + stable: loader.stable, + } + }) + .collect(), + }) + .collect(); + let manifest = daedalus::modded::Manifest { game_versions: all_game_versions .into_iter() .map(|game_version| { - let group = metadata_group_id_for_game_version( + let group = metadata_group_for_game_version( + &metadata_groups, mod_loader, &game_version.version, - ); + ) + .expect("game version should have a metadata group"); daedalus::modded::Version { id: game_version.version.clone(), stable: game_version.stable, - loaders: loaders - .iter() - .map(|loader| { - let version_path = metadata_version_path( - mod_loader, - format_version, - &loader.version, - group, - ); - - daedalus::modded::LoaderVersion { - id: loader.version.clone(), - url: format_url(&version_path), - stable: loader.stable, - } - }) - .collect(), + version_group: Some(group.id.to_string()), + loaders: Vec::new(), } }) .collect(), + version_groups, }; upload_files.insert( @@ -249,14 +266,14 @@ async fn fetch( .iter() .map(|loader| ProfileRequest { group: universal_group.id.to_string(), - template_game_version: universal_group - .template_game_version + loader_profile_template_game_version: universal_group + .loader_profile_template_game_version .clone(), loader_version: loader.version.clone(), url: format!( "{}/versions/loader/{}/{}/profile/json", meta_url, - universal_group.template_game_version, + universal_group.loader_profile_template_game_version, loader.version ), }) @@ -284,6 +301,7 @@ async fn fetch( let loader_versions = daedalus::modded::Version { id: DUMMY_REPLACE_STRING.to_string(), stable: true, + version_group: None, loaders: all_loader_versions .iter() .filter(|x| !skip_versions.contains(&&*x.version)) @@ -310,10 +328,12 @@ async fn fetch( daedalus::modded::Version { id: x.version, stable: x.stable, + version_group: None, loaders: vec![], } })) .collect(), + version_groups: Vec::new(), }; upload_files.insert( @@ -331,86 +351,13 @@ async fn fetch( }) } -struct MetadataGroup { - id: &'static str, - template_game_version: String, -} - -const UNIVERSAL_METADATA_GROUP: &str = "universal"; - struct ProfileRequest { group: String, - template_game_version: String, + loader_profile_template_game_version: String, loader_version: String, url: String, } -fn metadata_groups( - mod_loader: &str, - game_versions: &[FabricGameVersion], -) -> Vec { - if mod_loader != "quilt" { - return vec![MetadataGroup { - id: UNIVERSAL_METADATA_GROUP, - template_game_version: "1.21".to_string(), - }]; - } - - let pre_26_game_versions = game_versions - .iter() - .filter(|x| { - metadata_group_id_for_game_version(mod_loader, &x.version) - == "pre-26-x" - }) - .cloned() - .collect::>(); - let game_versions_26 = game_versions - .iter() - .filter(|x| { - metadata_group_id_for_game_version(mod_loader, &x.version) == "26-x" - }) - .cloned() - .collect::>(); - - let mut groups = Vec::new(); - - if !pre_26_game_versions.is_empty() { - groups.push(MetadataGroup { - id: "pre-26-x", - template_game_version: pre_26_game_versions - .iter() - .find(|x| x.version == "1.21") - .unwrap_or(&pre_26_game_versions[0]) - .version - .clone(), - }); - } - - if !game_versions_26.is_empty() { - groups.push(MetadataGroup { - id: "26-x", - template_game_version: game_versions_26[0].version.clone(), - }); - } - - groups -} - -fn metadata_group_id_for_game_version( - mod_loader: &str, - game_version: &str, -) -> &'static str { - if mod_loader == "quilt" - && (game_version.starts_with("26.") || game_version.starts_with("26w")) - { - "26-x" - } else if mod_loader == "quilt" { - "pre-26-x" - } else { - UNIVERSAL_METADATA_GROUP - } -} - fn metadata_version_path( mod_loader: &str, format_version: usize, @@ -421,7 +368,7 @@ fn metadata_version_path( format!("{mod_loader}/v{format_version}/versions/{loader_version}.json") } else { format!( - "{mod_loader}/v{format_version}/versions/{loader_version}/{group}.json" + "{mod_loader}/v{format_version}/version-group/{group}/loader-version/{loader_version}" ) } } @@ -451,7 +398,7 @@ async fn fetch_metadata_profiles( .map(|(mut version_info, request)| { patch_version_info( &mut version_info, - &request.template_game_version, + &request.loader_profile_template_game_version, maven_url, mirror_artifacts, )?; diff --git a/apps/daedalus_client/src/forge.rs b/apps/daedalus_client/src/forge.rs index 3e9293281f..45531ba99b 100644 --- a/apps/daedalus_client/src/forge.rs +++ b/apps/daedalus_client/src/forge.rs @@ -761,21 +761,23 @@ async fn fetch( .into_iter() .map(|(game_version, loaders)| { daedalus::modded::Version { - id: game_version, - stable: true, - loaders: loaders - .map(|x| daedalus::modded::LoaderVersion { - url: format_url(&format!( - "{mod_loader}/v{format_version}/versions/{}.json", - x.loader_version - )), - id: x.loader_version, - stable: false, - }) - .collect(), - } + id: game_version, + stable: true, + version_group: None, + loaders: loaders + .map(|x| daedalus::modded::LoaderVersion { + url: format_url(&format!( + "{mod_loader}/v{format_version}/versions/{}.json", + x.loader_version + )), + id: x.loader_version, + stable: false, + }) + .collect(), + } }) .collect(), + version_groups: Vec::new(), }; upload_files.insert( diff --git a/apps/daedalus_client/src/main.rs b/apps/daedalus_client/src/main.rs index 8b132a0c11..41e83fdb58 100644 --- a/apps/daedalus_client/src/main.rs +++ b/apps/daedalus_client/src/main.rs @@ -12,6 +12,7 @@ use tracing_subscriber::{EnvFilter, fmt, prelude::*}; mod error; mod fabric; mod forge; +mod metadata_groups; mod minecraft; pub mod util; diff --git a/apps/daedalus_client/src/metadata_groups.rs b/apps/daedalus_client/src/metadata_groups.rs new file mode 100644 index 0000000000..c0cd8828da --- /dev/null +++ b/apps/daedalus_client/src/metadata_groups.rs @@ -0,0 +1,132 @@ +//! Determines how version info is generated for pairs of game and loader +//! versions. +//! +//! When a user installs a version of the game, they install two things: the +//! game (of some specific version), and a loader (of some other specific +//! version). Each combination of game and loader version requires a specific +//! configuration, like a specific set of libraries that must be downloaded and +//! run along with the game. However, some versions of the game or loader may +//! change configuration requirements without the other version being affected. +//! For example, pre-26.x game versions with Quilt require the Quilt `hashed` +//! libraries to also be downloaded. However, 26.x and later don't require the +//! `hashed` libraries, and don't even have a download for them. The problem is +//! that Quilt loader 0.30.0 can be used for both pre-26.x and 26.x - but our v0 +//! manifest files can't differentiate the two. The result is that you either +//! break compatibility for 0.30.0 game versions pre-26.x, or break 0.30.0 +//! on 26.x and later. +//! +//! To fix this, v1 introduces the concept of *version groups*: game versions +//! before 26.x are version group v1, and 26.x and later are v2. Then, we +//! parameterize our version info on both version group and loader version, +//! letting us specify the right configuration based on both game version and +//! loader version. +//! +//! Why not parameterize on game version and loader version directly? Most game +//! versions have the same configuration as their surrounding game versions, so +//! we'd end up with many duplicate configurations: the number of game versions +//! multiplied by the number of loader versions. +//! +//! This file lets you configure what game versions are grouped together. +//! +//! Each version group is templated from a specific game version - e.g. game +//! version 1.21 is used as the template file for 1.20, 1.19, etc. + +pub const UNIVERSAL_METADATA_GROUP: &str = "universal"; +pub const QUILT_LEGACY_METADATA_GROUP: &str = "v1"; +pub const QUILT_MODERN_METADATA_GROUP: &str = "v2"; + +pub struct MetadataGroup { + pub id: &'static str, + /// Minecraft version used to fetch and template this group's loader profiles. + pub loader_profile_template_game_version: String, +} + +pub fn metadata_groups<'a>( + mod_loader: &str, + game_versions: impl IntoIterator, +) -> Vec { + // Non-Quilt loaders don't need the concept of version groups, so we just + // make one "universal" group, and template it on 1.21. + if mod_loader != "quilt" { + return vec![MetadataGroup { + id: UNIVERSAL_METADATA_GROUP, + loader_profile_template_game_version: "1.21".to_string(), + }]; + } + + let game_versions = game_versions.into_iter().collect::>(); + let legacy_game_versions = game_versions + .iter() + .copied() + .filter(|game_version| { + metadata_group_id_for_game_version(mod_loader, game_version) + == QUILT_LEGACY_METADATA_GROUP + }) + .collect::>(); + let modern_game_versions = game_versions + .iter() + .copied() + .filter(|game_version| { + metadata_group_id_for_game_version(mod_loader, game_version) + == QUILT_MODERN_METADATA_GROUP + }) + .collect::>(); + + let mut groups = Vec::new(); + + if !legacy_game_versions.is_empty() { + groups.push(MetadataGroup { + id: QUILT_LEGACY_METADATA_GROUP, + loader_profile_template_game_version: legacy_game_versions + .iter() + .find(|x| **x == "1.21") + .copied() + .unwrap_or(legacy_game_versions[0]) + .to_string(), + }); + } + + if !modern_game_versions.is_empty() { + groups.push(MetadataGroup { + id: QUILT_MODERN_METADATA_GROUP, + loader_profile_template_game_version: modern_game_versions[0] + .to_string(), + }); + } + + groups +} + +pub fn metadata_group_for_game_version<'a>( + groups: &'a [MetadataGroup], + mod_loader: &str, + game_version: &str, +) -> Option<&'a MetadataGroup> { + let group_id = metadata_group_id_for_game_version(mod_loader, game_version); + + groups.iter().find(|group| group.id == group_id) +} + +fn metadata_group_id_for_game_version( + mod_loader: &str, + game_version: &str, +) -> &'static str { + if mod_loader == "quilt" && is_modern_quilt_game_version(game_version) { + QUILT_MODERN_METADATA_GROUP + } else if mod_loader == "quilt" { + QUILT_LEGACY_METADATA_GROUP + } else { + UNIVERSAL_METADATA_GROUP + } +} + +// Update these Quilt group boundaries if upstream loader profiles gain another +// structural incompatibility between Minecraft versions. +fn is_modern_quilt_game_version(game_version: &str) -> bool { + let major = game_version + .split(['.', 'w']) + .next() + .and_then(|x| x.parse::().ok()); + + major.is_some_and(|x| x >= 26) +} diff --git a/packages/daedalus/src/modded.rs b/packages/daedalus/src/modded.rs index c306b651ef..f61c6bd503 100644 --- a/packages/daedalus/src/modded.rs +++ b/packages/daedalus/src/modded.rs @@ -10,7 +10,7 @@ pub const CURRENT_FABRIC_FORMAT_VERSION: usize = 0; /// The latest version of the format the fabric model structs deserialize to pub const CURRENT_FORGE_FORMAT_VERSION: usize = 0; /// The latest version of the format the quilt model structs deserialize to -pub const CURRENT_QUILT_FORMAT_VERSION: usize = 0; +pub const CURRENT_QUILT_FORMAT_VERSION: usize = 1; /// The latest version of the format the neoforge model structs deserialize to pub const CURRENT_NEOFORGE_FORMAT_VERSION: usize = 0; @@ -188,19 +188,36 @@ pub fn merge_partial_version( pub struct Manifest { /// The game versions the mod loader supports pub game_versions: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + /// Groups of game versions that share compatible loader version profiles + pub version_groups: Vec, } #[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] /// A game version of Minecraft pub struct Version { /// The minecraft version ID pub id: String, /// Whether the release is stable or not pub stable: bool, + #[serde(skip_serializing_if = "Option::is_none")] + /// The loader profile group for this Minecraft version + pub version_group: Option, /// A map that contains loader versions for the game version pub loaders: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +/// A group of Minecraft versions that share loader version profiles +pub struct VersionGroup { + /// The version group ID + pub id: String, + /// The loader versions for this version group + pub loaders: Vec, +} + #[derive(Serialize, Deserialize, Debug, Clone)] /// A version of a Minecraft mod loader pub struct LoaderVersion { From 59f88c992a6282f797ca1a83fea9a5dccee13f24 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Fri, 3 Jul 2026 18:47:08 +0100 Subject: [PATCH 4/4] wip: app-side logic --- .../InstallationSettings.vue | 23 +++- apps/app-frontend/src/helpers/types.d.ts | 7 ++ apps/daedalus_client/src/error.rs | 2 + apps/daedalus_client/src/main.rs | 106 ++++++++++++------ apps/daedalus_client/src/util.rs | 69 ++++++++++++ .../src/modules/launcher-meta/types.ts | 9 ++ .../src/modules/launcher-meta/v0.ts | 7 +- packages/app-lib/src/api/metadata.rs | 4 +- packages/app-lib/src/launcher/mod.rs | 47 +++++--- packages/app-lib/src/state/cache.rs | 25 +++-- packages/daedalus/src/modded.rs | 63 +++++++++++ .../components/CustomSetupStage.vue | 29 +++-- .../creation-flow-context.ts | 13 +-- .../server-settings/pages/installation.vue | 13 ++- 14 files changed, 335 insertions(+), 82 deletions(-) diff --git a/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue b/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue index 6d908eab2b..fa1a4d0312 100644 --- a/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue +++ b/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue @@ -269,16 +269,31 @@ provideInstallationSettings({ debug('resolveLoaderVersions: no manifest', { loader, gameVersion }) return [] } - if (loader === 'fabric' || loader === 'quilt') { - const result = manifest.gameVersions[0]?.loaders ?? [] - debug('resolveLoaderVersions: fabric/quilt result', { + const entry = manifest.gameVersions?.find((item) => item.id === gameVersion) + if (entry?.versionGroup) { + const result = + manifest.versionGroups?.find((group) => group.id === entry.versionGroup)?.loaders ?? [] + debug('resolveLoaderVersions: version group result', { loader, gameVersion, + versionGroup: entry.versionGroup, count: result.length, }) return result } - const result = manifest.gameVersions?.find((item) => item.id === gameVersion)?.loaders ?? [] + const placeholder = manifest.gameVersions?.find((item) => item.id === '${modrinth.gameVersion}') + if (placeholder) { + const result = manifest.gameVersions?.some((item) => item.id === gameVersion) + ? placeholder.loaders + : [] + debug('resolveLoaderVersions: placeholder result', { + loader, + gameVersion, + count: result.length, + }) + return result + } + const result = entry?.loaders ?? [] debug('resolveLoaderVersions: result', { loader, gameVersion, count: result.length }) return result }, diff --git a/apps/app-frontend/src/helpers/types.d.ts b/apps/app-frontend/src/helpers/types.d.ts index 0bd0101461..761226eb91 100644 --- a/apps/app-frontend/src/helpers/types.d.ts +++ b/apps/app-frontend/src/helpers/types.d.ts @@ -133,11 +133,18 @@ type Hooks = { type Manifest = { gameVersions: ManifestGameVersion[] + versionGroups?: ManifestVersionGroup[] } type ManifestGameVersion = { id: string stable: boolean + versionGroup?: string + loaders: ManifestLoaderVersion[] +} + +type ManifestVersionGroup = { + id: string loaders: ManifestLoaderVersion[] } diff --git a/apps/daedalus_client/src/error.rs b/apps/daedalus_client/src/error.rs index 6765341381..6afb321df4 100644 --- a/apps/daedalus_client/src/error.rs +++ b/apps/daedalus_client/src/error.rs @@ -27,6 +27,8 @@ pub enum ErrorKind { inner: Box, file: String, }, + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), #[error("Error acquiring semaphore: {0}")] Acquire(#[from] tokio::sync::AcquireError), #[error("Tracing error: {0}")] diff --git a/apps/daedalus_client/src/main.rs b/apps/daedalus_client/src/main.rs index 41e83fdb58..5f6900889f 100644 --- a/apps/daedalus_client/src/main.rs +++ b/apps/daedalus_client/src/main.rs @@ -1,6 +1,7 @@ use crate::util::{ REQWEST_CLIENT, format_url, upload_file_to_bucket, - upload_url_to_bucket_mirrors, + upload_url_to_bucket_mirrors, write_file_to_local_output, + write_url_to_local_output_mirrors, }; use daedalus::get_path_from_artifact; use dashmap::{DashMap, DashSet}; @@ -76,36 +77,67 @@ async fn main() -> Result<()> { mirror_artifacts, } = fetch_result; - futures::future::try_join_all(upload_files.iter().map(|entry| { - upload_file_to_bucket( - entry.key().clone(), - entry.value().file.clone(), - entry.value().content_type.clone(), - &semaphore, - ) - })) - .await?; - - futures::future::try_join_all(mirror_artifacts.iter().map(|entry| { - upload_url_to_bucket_mirrors( - format!("maven/{}", entry.key()), - entry - .value() - .mirrors - .iter() - .map(|mirror| { - if mirror.entire_url { - mirror.path.clone() - } else { - format!("{}{}", mirror.path, entry.key()) - } - }) - .collect(), - entry.value().sha1.clone(), - &semaphore, - ) - })) - .await?; + if dotenvy::var("LOCAL_OUTPUT_DIR").is_ok() { + futures::future::try_join_all(upload_files.iter().map(|entry| { + let path = entry.key().clone(); + let file = entry.value().file.clone(); + + async move { write_file_to_local_output(&path, file).await } + })) + .await?; + + futures::future::try_join_all(mirror_artifacts.iter().map(|entry| { + write_url_to_local_output_mirrors( + format!("maven/{}", entry.key()), + entry + .value() + .mirrors + .iter() + .map(|mirror| { + if mirror.entire_url { + mirror.path.clone() + } else { + format!("{}{}", mirror.path, entry.key()) + } + }) + .collect(), + entry.value().sha1.clone(), + &semaphore, + ) + })) + .await?; + } else { + futures::future::try_join_all(upload_files.iter().map(|entry| { + upload_file_to_bucket( + entry.key().clone(), + entry.value().file.clone(), + entry.value().content_type.clone(), + &semaphore, + ) + })) + .await?; + + futures::future::try_join_all(mirror_artifacts.iter().map(|entry| { + upload_url_to_bucket_mirrors( + format!("maven/{}", entry.key()), + entry + .value() + .mirrors + .iter() + .map(|mirror| { + if mirror.entire_url { + mirror.path.clone() + } else { + format!("{}{}", mirror.path, entry.key()) + } + }) + .collect(), + entry.value().sha1.clone(), + &semaphore, + ) + })) + .await?; + } if dotenvy::var("CLOUDFLARE_INTEGRATION") .ok() @@ -249,11 +281,13 @@ fn check_env_vars() -> bool { failed |= check_var::("BASE_URL"); - failed |= check_var::("S3_ACCESS_TOKEN"); - failed |= check_var::("S3_SECRET"); - failed |= check_var::("S3_URL"); - failed |= check_var::("S3_REGION"); - failed |= check_var::("S3_BUCKET_NAME"); + if dotenvy::var("LOCAL_OUTPUT_DIR").is_err() { + failed |= check_var::("S3_ACCESS_TOKEN"); + failed |= check_var::("S3_SECRET"); + failed |= check_var::("S3_URL"); + failed |= check_var::("S3_REGION"); + failed |= check_var::("S3_BUCKET_NAME"); + } if dotenvy::var("CLOUDFLARE_INTEGRATION") .ok() diff --git a/apps/daedalus_client/src/util.rs b/apps/daedalus_client/src/util.rs index 6d331d839e..87afec1d43 100644 --- a/apps/daedalus_client/src/util.rs +++ b/apps/daedalus_client/src/util.rs @@ -3,6 +3,7 @@ use bytes::Bytes; use s3::creds::Credentials; use s3::{Bucket, Region}; use serde::de::DeserializeOwned; +use std::path::PathBuf; use std::sync::{Arc, LazyLock}; use tokio::sync::Semaphore; @@ -135,6 +136,74 @@ pub async fn upload_url_to_bucket( Ok(()) } +pub async fn write_file_to_local_output( + path: &str, + bytes: Bytes, +) -> Result<(), Error> { + let output_path = local_output_path(path)?; + + if let Some(parent) = output_path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + + tokio::fs::write(output_path, bytes).await?; + + Ok(()) +} + +pub async fn write_url_to_local_output_mirrors( + output_path: String, + mirrors: Vec, + sha1: Option, + semaphore: &Arc, +) -> Result<(), Error> { + if mirrors.is_empty() { + return Err(ErrorKind::InvalidInput( + "No mirrors provided!".to_string(), + ) + .into()); + } + + for (index, mirror) in mirrors.iter().enumerate() { + let result = write_url_to_local_output( + output_path.clone(), + mirror.clone(), + sha1.clone(), + semaphore, + ) + .await; + + if result.is_ok() || (result.is_err() && index == (mirrors.len() - 1)) { + return result; + } + } + + unreachable!() +} + +async fn write_url_to_local_output( + path: String, + url: String, + sha1: Option, + semaphore: &Arc, +) -> Result<(), Error> { + let data = download_file(&url, sha1.as_deref(), semaphore).await?; + + write_file_to_local_output(&path, data).await?; + + Ok(()) +} + +fn local_output_path(path: &str) -> Result { + let output_dir = dotenvy::var("LOCAL_OUTPUT_DIR").map_err(|_| { + ErrorKind::InvalidInput( + "LOCAL_OUTPUT_DIR is required for local output".to_string(), + ) + })?; + + Ok(PathBuf::from(output_dir).join(path)) +} + #[tracing::instrument(skip(bytes))] pub async fn sha1_async(bytes: Bytes) -> Result { let hash = tokio::task::spawn_blocking(move || { diff --git a/packages/api-client/src/modules/launcher-meta/types.ts b/packages/api-client/src/modules/launcher-meta/types.ts index 309bf1eca0..20e0dcee76 100644 --- a/packages/api-client/src/modules/launcher-meta/types.ts +++ b/packages/api-client/src/modules/launcher-meta/types.ts @@ -3,16 +3,25 @@ export namespace LauncherMeta { export namespace v0 { export type LoaderVersion = { id: string + url: string stable: boolean } export type GameVersionEntry = { + id: string + stable: boolean + versionGroup?: string + loaders: LoaderVersion[] + } + + export type VersionGroup = { id: string loaders: LoaderVersion[] } export type Manifest = { gameVersions: GameVersionEntry[] + versionGroups?: VersionGroup[] } } } diff --git a/packages/api-client/src/modules/launcher-meta/v0.ts b/packages/api-client/src/modules/launcher-meta/v0.ts index f4d6ff0e3b..391d22f60f 100644 --- a/packages/api-client/src/modules/launcher-meta/v0.ts +++ b/packages/api-client/src/modules/launcher-meta/v0.ts @@ -20,10 +20,13 @@ export class LauncherMetaManifestV0Module extends AbstractModule { * * @param loader - Loader platform (fabric, forge, quilt, neo) */ - public async getManifest(loader: string): Promise { + public async getManifest( + loader: string, + formatVersion = 0, + ): Promise { return this.client.request('/manifest.json', { api: LAUNCHER_META_BASE_URL, - version: `${loader}/v0`, + version: `${loader}/v${formatVersion}`, method: 'GET', skipAuth: true, headers: { 'Content-Type': '' }, diff --git a/packages/app-lib/src/api/metadata.rs b/packages/app-lib/src/api/metadata.rs index 59b3b89c56..befad43b9f 100644 --- a/packages/app-lib/src/api/metadata.rs +++ b/packages/app-lib/src/api/metadata.rs @@ -22,8 +22,10 @@ pub async fn get_minecraft_versions() -> crate::Result { // #[tracing::instrument] pub async fn get_loader_versions(loader: &str) -> crate::Result { let state = State::get().await?; + let cache_key = + daedalus::modded::loader_manifest_metadata(loader).cache_key; let loaders = CachedEntry::get_loader_manifest( - loader, + &cache_key, None, &state.pool, &state.api_semaphore, diff --git a/packages/app-lib/src/launcher/mod.rs b/packages/app-lib/src/launcher/mod.rs index a3d4a0a8b1..ea50297f9c 100644 --- a/packages/app-lib/src/launcher/mod.rs +++ b/packages/app-lib/src/launcher/mod.rs @@ -24,7 +24,7 @@ use crate::{State, get_resource_file, process}; use chrono::Utc; use daedalus as d; use daedalus::minecraft::{LoggingSide, RuleAction, VersionInfo}; -use daedalus::modded::LoaderVersion; +use daedalus::modded::{LoaderVersion, Manifest}; use regex::Regex; use serde::Deserialize; use std::fmt::Write; @@ -175,19 +175,18 @@ pub async fn get_loader_version_from_profile( let versions = crate::api::metadata::get_loader_versions(loader.as_meta_str()).await?; - let loaders = versions.game_versions.into_iter().find(|x| { - x.id.replace(daedalus::modded::DUMMY_REPLACE_STRING, game_version) - == game_version - }); - - if let Some(loaders) = loaders { - let loader_version = loaders.loaders.iter().find(|x| filter(x)).or( - if version == "stable" { - loaders.loaders.first() - } else { - None - }, - ); + if let Some(loaders) = + loader_versions_for_game_version(&versions, game_version) + { + let loader_version = + loaders + .iter() + .find(|x| filter(x)) + .or(if version == "stable" { + loaders.first() + } else { + None + }); Ok(loader_version.cloned()) } else { @@ -195,6 +194,26 @@ pub async fn get_loader_version_from_profile( } } +fn loader_versions_for_game_version<'a>( + manifest: &'a Manifest, + game_version: &str, +) -> Option<&'a [LoaderVersion]> { + let version = manifest.game_versions.iter().find(|x| { + x.id.replace(daedalus::modded::DUMMY_REPLACE_STRING, game_version) + == game_version + })?; + + if let Some(version_group) = &version.version_group { + manifest + .version_groups + .iter() + .find(|group| group.id == *version_group) + .map(|group| group.loaders.as_slice()) + } else { + Some(version.loaders.as_slice()) + } +} + /// Resolves the Minecraft version manifest and finds the index for the given /// game version. If the version isn't found in the cache, forces a manifest /// refresh to pick up newly-released versions. diff --git a/packages/app-lib/src/state/cache.rs b/packages/app-lib/src/state/cache.rs index e04104cbae..a65c2844fa 100644 --- a/packages/app-lib/src/state/cache.rs +++ b/packages/app-lib/src/state/cache.rs @@ -1396,19 +1396,25 @@ impl CachedEntry { let fetch_urls = keys .iter() .map(|x| { + let metadata = + daedalus::modded::loader_manifest_metadata_from_cache_key( + &x.key().to_string(), + ); + ( - x.key().to_string(), + metadata.cache_key, + metadata.loader, format!( - "{}{}/v0/manifest.json", + "{}{}", env!("MODRINTH_LAUNCHER_META_URL"), - x.key() + metadata.path, ), ) }) .collect::>(); futures::future::try_join_all(fetch_urls.iter().map( - |(_, url)| { + |(_, _, url)| { fetch_json( Method::GET, url, @@ -1424,14 +1430,15 @@ impl CachedEntry { .into_iter() .enumerate() .map(|(index, metadata)| { - ( + let mut entry = CacheValue::LoaderManifest(CachedLoaderManifest { - loader: fetch_urls[index].0.to_string(), + loader: fetch_urls[index].1.to_string(), manifest: metadata, }) - .get_entry(), - true, - ) + .get_entry(); + entry.id.clone_from(&fetch_urls[index].0); + + (entry, true) }) .collect() } diff --git a/packages/daedalus/src/modded.rs b/packages/daedalus/src/modded.rs index f61c6bd503..6d121b357e 100644 --- a/packages/daedalus/src/modded.rs +++ b/packages/daedalus/src/modded.rs @@ -14,6 +14,69 @@ pub const CURRENT_QUILT_FORMAT_VERSION: usize = 1; /// The latest version of the format the neoforge model structs deserialize to pub const CURRENT_NEOFORGE_FORMAT_VERSION: usize = 0; +/// Metadata for locating and caching a loader manifest. +#[derive(Debug, Clone)] +pub struct LoaderManifestMetadata { + /// The canonical loader name used in launcher-meta paths. + pub loader: String, + /// The latest manifest format version for this loader. + pub format_version: usize, + /// The cache key that includes the loader format version. + pub cache_key: String, + /// The launcher-meta path to the manifest. + pub path: String, +} + +/// Returns metadata for the latest manifest format for the provided loader. +pub fn loader_manifest_metadata(loader: &str) -> LoaderManifestMetadata { + let format_version = current_loader_manifest_format_version(loader); + let cache_key = format!("{loader}-v{format_version}"); + let path = format!("{loader}/v{format_version}/manifest.json"); + + LoaderManifestMetadata { + loader: loader.to_string(), + format_version, + cache_key, + path, + } +} + +/// Returns loader manifest metadata from a versioned cache key. +pub fn loader_manifest_metadata_from_cache_key( + cache_key: &str, +) -> LoaderManifestMetadata { + if let Some((loader, format_version)) = + cache_key.rsplit_once("-v").and_then(|(loader, version)| { + version + .parse::() + .ok() + .map(|version| (loader, version)) + }) + { + let cache_key = format!("{loader}-v{format_version}"); + let path = format!("{loader}/v{format_version}/manifest.json"); + + LoaderManifestMetadata { + loader: loader.to_string(), + format_version, + cache_key, + path, + } + } else { + loader_manifest_metadata(cache_key) + } +} + +fn current_loader_manifest_format_version(loader: &str) -> usize { + match loader { + "fabric" => CURRENT_FABRIC_FORMAT_VERSION, + "forge" => CURRENT_FORGE_FORMAT_VERSION, + "quilt" => CURRENT_QUILT_FORMAT_VERSION, + "neo" => CURRENT_NEOFORGE_FORMAT_VERSION, + _ => 0, + } +} + /// The dummy replace string library names, inheritsFrom, and version names should be replaced with pub const DUMMY_REPLACE_STRING: &str = "${modrinth.gameVersion}"; diff --git a/packages/ui/src/components/flows/creation-flow-modal/components/CustomSetupStage.vue b/packages/ui/src/components/flows/creation-flow-modal/components/CustomSetupStage.vue index fa5125ef99..2d20d1a1a0 100644 --- a/packages/ui/src/components/flows/creation-flow-modal/components/CustomSetupStage.vue +++ b/packages/ui/src/components/flows/creation-flow-modal/components/CustomSetupStage.vue @@ -370,11 +370,13 @@ const gameVersionOptions = computed[]>(() => { const manifest = ctx.loaderVersionsCache.value[apiLoader] if (!manifest) return [] - const hasPlaceholder = manifest.some((x) => x.id === '${modrinth.gameVersion}') + const hasPlaceholder = manifest.gameVersions.some((x) => x.id === '${modrinth.gameVersion}') const supportedVersions = new Set( - manifest + manifest.gameVersions .filter( - (x) => x.id !== '${modrinth.gameVersion}' && (hasPlaceholder || x.loaders.length > 0), + (x) => + x.id !== '${modrinth.gameVersion}' && + (hasPlaceholder || x.loaders.length > 0 || !!x.versionGroup), ) .map((x) => x.id), ) @@ -466,14 +468,14 @@ function getLoaderVersionsForGameVersion( apiLoader, gameVersion, hasManifest: !!manifest, - manifestLength: manifest?.length, + manifestLength: manifest?.gameVersions.length, }) if (!manifest) return [] // Some loaders (e.g. Fabric) list all versions under a placeholder entry - const placeholder = manifest.find((x) => x.id === '${modrinth.gameVersion}') + const placeholder = manifest.gameVersions.find((x) => x.id === '${modrinth.gameVersion}') if (placeholder) { - if (!manifest.some((x) => x.id === gameVersion)) return [] + if (!manifest.gameVersions.some((x) => x.id === gameVersion)) return [] debug( 'getLoaderVersionsForGameVersion: using placeholder, loaders:', placeholder.loaders.length, @@ -481,7 +483,20 @@ function getLoaderVersionsForGameVersion( return placeholder.loaders } - const entry = manifest.find((x) => x.id === gameVersion) + const entry = manifest.gameVersions.find((x) => x.id === gameVersion) + if (entry?.versionGroup) { + const loaders = + manifest.versionGroups?.find((group) => group.id === entry.versionGroup)?.loaders ?? [] + debug( + 'getLoaderVersionsForGameVersion: version group for', + gameVersion, + ':', + entry.versionGroup, + loaders.length + ' loaders', + ) + return loaders + } + debug( 'getLoaderVersionsForGameVersion: entry for', gameVersion, diff --git a/packages/ui/src/components/flows/creation-flow-modal/creation-flow-context.ts b/packages/ui/src/components/flows/creation-flow-modal/creation-flow-context.ts index 9582c4df8a..e63935c56a 100644 --- a/packages/ui/src/components/flows/creation-flow-modal/creation-flow-context.ts +++ b/packages/ui/src/components/flows/creation-flow-modal/creation-flow-context.ts @@ -24,7 +24,8 @@ export type Gamemode = 'survival' | 'creative' | 'hardcore' export type Difficulty = 'peaceful' | 'easy' | 'normal' | 'hard' export type LoaderVersionType = 'stable' | 'latest' | 'other' export type GeneratorSettingsMode = 'default' | 'flat' | 'custom' -export type LoaderManifestResolver = (loader: string) => Promise +export type LoaderManifest = LauncherMeta.Manifest.v0.Manifest +export type LoaderManifestResolver = (loader: string) => Promise export interface LoaderVersionEntry { id: string stable: boolean @@ -160,7 +161,7 @@ export interface CreationFlowContextValue { hideLoaderChips: ComputedRef hideLoaderVersion: ComputedRef showSnapshots: Ref - loaderVersionsCache: Ref> + loaderVersionsCache: Ref> paperSupportedVersions: Ref | null> purpurSupportedVersions: Ref | null> @@ -295,9 +296,7 @@ export function createCreationFlowContext( const loaderVersionType = ref('stable') const selectedLoaderVersion = ref(null) const showSnapshots = ref(false) - const loaderVersionsCache = ref>( - {}, - ) + const loaderVersionsCache = ref>({}) const paperSupportedVersions = ref | null>(null) const purpurSupportedVersions = ref | null>(null) @@ -364,11 +363,11 @@ export function createCreationFlowContext( (await client.launchermeta.manifest_v0.getManifest(apiLoader)), staleTime: Infinity, }) - loaderVersionsCache.value[apiLoader] = data.gameVersions + loaderVersionsCache.value[apiLoader] = data debug('fetchLoaderManifest: loaded', apiLoader, 'gameVersions:', data.gameVersions.length) } catch (error) { debug('fetchLoaderManifest: failed', apiLoader, error) - loaderVersionsCache.value[apiLoader] = [] + loaderVersionsCache.value[apiLoader] = { gameVersions: [] } } } diff --git a/packages/ui/src/layouts/shared/server-settings/pages/installation.vue b/packages/ui/src/layouts/shared/server-settings/pages/installation.vue index 15035cd77a..7d1dea789c 100644 --- a/packages/ui/src/layouts/shared/server-settings/pages/installation.vue +++ b/packages/ui/src/layouts/shared/server-settings/pages/installation.vue @@ -376,12 +376,17 @@ function getLoaderVersionsForGameVersion( } const manifest = manifestQuery.data.value?.gameVersions + const versionGroups = manifestQuery.data.value?.versionGroups if (!manifest) return [] const placeholder = manifest.find((x) => x.id === '${modrinth.gameVersion}') if (placeholder) return placeholder.loaders const entry = manifest.find((x) => x.id === gameVersion) + if (entry?.versionGroup) { + return versionGroups?.find((group) => group.id === entry.versionGroup)?.loaders ?? [] + } + return entry?.loaders ?? [] } @@ -489,7 +494,9 @@ provideInstallationSettings({ const hasPlaceholder = manifest.some((x) => x.id === '${modrinth.gameVersion}') if (!hasPlaceholder) { const supportedVersions = new Set( - manifest.filter((x) => x.loaders.length > 0).map((x) => x.id), + manifest + .filter((x) => x.loaders.length > 0 || !!x.versionGroup) + .map((x) => x.id), ) return versions .filter((v) => supportedVersions.has(v.version)) @@ -531,7 +538,9 @@ provideInstallationSettings({ if (hasPlaceholder) { return tags.gameVersions.value.some((v) => v.version_type !== 'release') } - const supportedVersions = new Set(manifest.filter((x) => x.loaders.length > 0).map((x) => x.id)) + const supportedVersions = new Set( + manifest.filter((x) => x.loaders.length > 0 || !!x.versionGroup).map((x) => x.id), + ) const supported = tags.gameVersions.value.filter((v) => supportedVersions.has(v.version)) return supported.some((v) => v.version_type !== 'release') },