use crate::State; use crate::state::CachedLoaderManifest; use crate::util::fetch::{FetchSemaphore, fetch, fetch_json, fetch_official}; use crate::util::io; use daedalus::minecraft::{ Argument, ArgumentType, JavaVersion, Library, VersionType, }; use daedalus::modded::{ DUMMY_REPLACE_STRING, LoaderProfileSource, LoaderVersion, Manifest, PartialVersionInfo, Processor, SidedDataEntry, Version, VersionGroup, }; use reqwest::Method; use serde::Deserialize; use sqlx::SqlitePool; use std::cmp::Ordering; use std::collections::{HashMap, HashSet}; use std::io::{Cursor, Read}; const FABRIC_META_URL: &str = "https://meta.fabricmc.net/v2/versions/"; const QUILT_META_URL: &str = "https://meta.quiltmc.org/v3/versions/"; const LEGACY_FABRIC_META_URL: &str = "https://meta.legacyfabric.net/v2/versions/"; const BABRIC_META_URL: &str = "https://meta.babric.dev/v2/versions/"; const BABRIC_GAME_VERSION: &str = "b1.7.3"; const BABRIC_MAVEN_URL: &str = "https://maven.glass-launcher.net/babric/"; const BABRIC_LOADER_METADATA_URL: &str = "https://maven.glass-launcher.net/babric/babric/fabric-loader/"; const BABRIC_POLYFILL_VERSION_URL: &str = "https://babric.github.io/manifest-polyfill/b1.7.3.json"; const BABRIC_FALLBACK_LOADER_VERSION: &str = "0.15.6-babric.2"; const OPTIFINE_META_URL: &str = "https://bmclapi2.bangbang93.com/optifine"; const OPTIFINE_VERSION_LIST_URL: &str = "https://bmclapi2.bangbang93.com/optifine/versionList"; const LITELOADER_META_URL: &str = "https://dl.liteloader.com/versions/versions.json"; const CLEANROOM_RELEASES_URL: &str = "https://api.github.com/repos/CleanroomMC/Cleanroom/releases?per_page=100"; const FORGE_MAVEN_URL: &str = "https://maven.minecraftforge.net/net/minecraftforge/forge/"; const FORGE_PROMOTIONS_URL: &str = "https://files.minecraftforge.net/net/minecraftforge/forge/promotions_slim.json"; const NEOFORGE_MAVEN_URL: &str = "https://maven.neoforged.net/releases/net/neoforged/neoforge/"; const NEOFORGE_LEGACY_MAVEN_URL: &str = "https://maven.neoforged.net/releases/net/neoforged/forge/"; const INSTALLER_URL_KEY: &str = "AXOLOTL_INSTALLER_URL"; const INSTALLER_ENTRY_KEY_PREFIX: &str = "AXOLOTL_INSTALLER_ENTRY_"; #[derive(Deserialize)] struct MetaGameVersion { version: String, stable: bool, } #[derive(Deserialize)] struct FabricLoaderVersion { version: String, stable: bool, } #[derive(Deserialize)] struct QuiltLoaderVersion { version: String, } #[derive(Deserialize)] struct FabricLoaderMetadata { loader: FabricLoaderVersion, } #[derive(Deserialize)] struct QuiltLoaderMetadata { loader: QuiltLoaderVersion, } #[derive(Deserialize)] struct LegacyFabricMetadata { game: Vec, loader: Vec, } #[derive(Deserialize)] struct OptiFineMetadata { mcversion: String, #[serde(rename = "type")] type_: String, patch: String, } #[derive(Deserialize)] struct GithubRelease { tag_name: String, draft: bool, prerelease: bool, #[serde(default)] assets: Vec, } #[derive(Deserialize)] struct GithubReleaseAsset { name: String, browser_download_url: String, } #[derive(Deserialize)] struct MavenMetadata { versioning: MavenVersioning, } #[derive(Deserialize)] struct MavenVersioning { #[serde(default)] release: String, versions: MavenVersions, } #[derive(Deserialize)] struct MavenVersions { #[serde(rename = "version", default)] values: Vec, } #[derive(Deserialize)] struct BabricLauncherMetadata { #[serde(default)] libraries: BabricLauncherLibraries, #[serde(rename = "mainClass")] main_class: BabricMainClass, #[serde(default)] min_java_version: Option, } #[derive(Deserialize, Default)] struct BabricLauncherLibraries { #[serde(default)] common: Vec, #[serde(default)] client: Vec, } #[derive(Deserialize)] struct BabricMainClass { client: String, } #[derive(Deserialize, Default)] struct ForgePromotions { #[serde(default)] promos: HashMap, } #[derive(Deserialize)] struct InstallerProfile { #[serde(default)] minecraft: Option, #[serde(default)] data: HashMap, #[serde(default)] processors: Vec, #[serde(default)] libraries: Vec, #[serde(default)] install: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct LegacyInstallerArtifact { path: String, file_path: String, } struct InstallerArtifact { coordinate: String, data: Vec, } struct ParsedInstaller { profile: PartialVersionInfo, artifacts: Vec, } pub(crate) async fn fetch_loader_manifest_official_first( loader: &str, game_version: Option<&str>, fallback_url: &str, fetch_semaphore: &FetchSemaphore, pool: &SqlitePool, ) -> crate::Result { let official = match (loader, game_version) { ("fabric", Some(game_version)) => { fetch_fabric_manifest_for_game(game_version, fetch_semaphore, pool) .await } ("quilt", Some(game_version)) => { fetch_quilt_manifest_for_game(game_version, fetch_semaphore, pool) .await } ("legacy_fabric", Some(game_version)) => { fetch_legacy_fabric_manifest(fetch_semaphore, pool) .await .map(|manifest| scope_manifest(manifest, game_version)) } ("babric", Some(game_version)) => { fetch_babric_manifest_for_game(game_version, fetch_semaphore, pool) .await } ("optifine", Some(game_version)) => { fetch_optifine_manifest(Some(game_version), fetch_semaphore, pool) .await } ("lite_loader", Some(game_version)) => { fetch_liteloader_manifest(fetch_semaphore, pool) .await .map(|manifest| scope_manifest(manifest, game_version)) } ("cleanroom", Some(game_version)) => { fetch_cleanroom_manifest(fetch_semaphore, pool) .await .map(|manifest| scope_manifest(manifest, game_version)) } ("forge", Some(game_version)) => { fetch_forge_manifest(fetch_semaphore, pool) .await .map(|manifest| scope_manifest(manifest, game_version)) } ("neo", Some(game_version)) => { fetch_neoforge_manifest(fetch_semaphore, pool) .await .map(|manifest| scope_manifest(manifest, game_version)) } ("fabric", None) => fetch_fabric_manifest(fetch_semaphore, pool).await, ("forge", None) => fetch_forge_manifest(fetch_semaphore, pool).await, ("neo", None) => fetch_neoforge_manifest(fetch_semaphore, pool).await, ("quilt", None) => fetch_quilt_manifest(fetch_semaphore, pool).await, ("legacy_fabric", None) => { fetch_legacy_fabric_manifest(fetch_semaphore, pool).await } ("babric", None) => fetch_babric_manifest(fetch_semaphore, pool).await, ("optifine", None) => { fetch_optifine_manifest(None, fetch_semaphore, pool).await } ("lite_loader", None) => { fetch_liteloader_manifest(fetch_semaphore, pool).await } ("cleanroom", None) => { fetch_cleanroom_manifest(fetch_semaphore, pool).await } _ => { return fetch_fallback_manifest( loader, game_version, fallback_url, fetch_semaphore, pool, ) .await; } }; let official = official.and_then(|manifest| { if let Some(game_version) = game_version { validate_scoped_manifest(manifest, game_version) } else { validate_manifest(manifest) } }); match official { Ok(manifest) => Ok(CachedLoaderManifest { loader: loader.to_string(), manifest, }), Err(error) => { tracing::warn!( loader, fallback_url, error = %error, "Official loader metadata failed; using launcher-meta fallback" ); fetch_fallback_manifest( loader, game_version, fallback_url, fetch_semaphore, pool, ) .await } } } async fn fetch_fallback_manifest( loader: &str, game_version: Option<&str>, fallback_url: &str, fetch_semaphore: &FetchSemaphore, pool: &SqlitePool, ) -> crate::Result { let mut manifest: Manifest = fetch_json( Method::GET, fallback_url, None, None, None, fetch_semaphore, pool, ) .await?; if loader == "lite_loader" { manifest .game_versions .retain(|version| liteloader_supports_game_version(&version.id)); } let manifest = if let Some(game_version) = game_version { scope_manifest(manifest, game_version) } else { manifest }; Ok(CachedLoaderManifest { loader: loader.to_string(), manifest, }) } fn validate_scoped_manifest( manifest: Manifest, game_version: &str, ) -> crate::Result { if manifest.game_versions.len() != 1 || manifest.game_versions[0].id != game_version { return Err(crate::ErrorKind::OtherError(format!( "Loader metadata response was not scoped to Minecraft {game_version}" )) .as_error()); } Ok(manifest) } fn validate_manifest(manifest: Manifest) -> crate::Result { let loader_count = manifest .game_versions .iter() .map(|version| version.loaders.len()) .chain( manifest .version_groups .iter() .map(|group| group.loaders.len()), ) .sum::(); if manifest.game_versions.is_empty() || loader_count == 0 { return Err(crate::ErrorKind::OtherError( "Official loader metadata returned no installable versions" .to_string(), ) .as_error()); } Ok(manifest) } async fn fetch_fabric_manifest( fetch_semaphore: &FetchSemaphore, pool: &SqlitePool, ) -> crate::Result { let game_url = format!("{FABRIC_META_URL}game"); let loader_url = format!("{FABRIC_META_URL}loader"); let (games, loaders) = tokio::try_join!( fetch_json::>( Method::GET, &game_url, None, None, None, fetch_semaphore, pool, ), fetch_json::>( Method::GET, &loader_url, None, None, None, fetch_semaphore, pool, ), )?; Ok(meta_api_manifest( games, loaders .into_iter() .map(|loader| (loader.version, loader.stable)) .collect(), "fabric", FABRIC_META_URL, 0, )) } async fn fetch_fabric_manifest_for_game( game_version: &str, fetch_semaphore: &FetchSemaphore, pool: &SqlitePool, ) -> crate::Result { let url = meta_loader_versions_url(FABRIC_META_URL, game_version); let loaders = fetch_json::>( Method::GET, &url, None, None, None, fetch_semaphore, pool, ) .await? .into_iter() .map(|metadata| (metadata.loader.version, metadata.loader.stable)) .collect(); Ok(meta_api_manifest_for_game( game_version, loaders, "fabric", FABRIC_META_URL, 0, )) } async fn fetch_quilt_manifest( fetch_semaphore: &FetchSemaphore, pool: &SqlitePool, ) -> crate::Result { let game_url = format!("{QUILT_META_URL}game"); let loader_url = format!("{QUILT_META_URL}loader"); let (games, loaders) = tokio::try_join!( fetch_json::>( Method::GET, &game_url, None, None, None, fetch_semaphore, pool, ), fetch_json::>( Method::GET, &loader_url, None, None, None, fetch_semaphore, pool, ), )?; Ok(meta_api_manifest( games, loaders .into_iter() .map(|loader| { let stable = is_stable_version(&loader.version); (loader.version, stable) }) .collect(), "quilt", QUILT_META_URL, 1, )) } async fn fetch_quilt_manifest_for_game( game_version: &str, fetch_semaphore: &FetchSemaphore, pool: &SqlitePool, ) -> crate::Result { let url = meta_loader_versions_url(QUILT_META_URL, game_version); let loaders = fetch_json::>( Method::GET, &url, None, None, None, fetch_semaphore, pool, ) .await? .into_iter() .map(|metadata| { let version = metadata.loader.version; let stable = is_stable_version(&version); (version, stable) }) .collect(); Ok(meta_api_manifest_for_game( game_version, loaders, "quilt", QUILT_META_URL, 1, )) } async fn fetch_legacy_fabric_manifest( fetch_semaphore: &FetchSemaphore, pool: &SqlitePool, ) -> crate::Result { let metadata: LegacyFabricMetadata = fetch_json( Method::GET, LEGACY_FABRIC_META_URL.trim_end_matches('/'), None, None, None, fetch_semaphore, pool, ) .await?; Ok(meta_api_manifest( metadata.game, metadata .loader .into_iter() .map(|loader| (loader.version, loader.stable)) .collect(), "legacy_fabric", LEGACY_FABRIC_META_URL, 0, )) } async fn fetch_babric_manifest( fetch_semaphore: &FetchSemaphore, pool: &SqlitePool, ) -> crate::Result { fetch_babric_manifest_for_game(BABRIC_GAME_VERSION, fetch_semaphore, pool) .await } async fn fetch_babric_manifest_for_game( game_version: &str, fetch_semaphore: &FetchSemaphore, pool: &SqlitePool, ) -> crate::Result { if game_version != BABRIC_GAME_VERSION { return Ok(babric_unsupported_manifest(game_version)); } let metadata_url = format!("{BABRIC_LOADER_METADATA_URL}maven-metadata.xml"); let metadata = fetch_maven_metadata(&metadata_url, fetch_semaphore, pool) .await .ok(); let versions = metadata .map(|metadata| metadata.versioning.versions.values) .filter(|versions| !versions.is_empty()) .unwrap_or_else(|| vec![BABRIC_FALLBACK_LOADER_VERSION.to_string()]); Ok(babric_manifest(game_version, versions)) } fn babric_unsupported_manifest(game_version: &str) -> Manifest { Manifest { game_versions: vec![Version { id: game_version.to_string(), stable: true, version_group: None, loaders: Vec::new(), }], version_groups: Vec::new(), } } fn babric_manifest(game_version: &str, versions: Vec) -> Manifest { let mut loaders = versions .into_iter() .map(|version| LoaderVersion { id: version.clone(), url: format!( "{BABRIC_META_URL}loader/{game_version}/{version}/profile/json" ), stable: version == BABRIC_FALLBACK_LOADER_VERSION, profile_source: LoaderProfileSource::Babric, fallback_url: None, }) .collect::>(); loaders.sort_by(|left, right| compare_versions(&right.id, &left.id)); Manifest { game_versions: vec![Version { id: game_version.to_string(), stable: true, version_group: None, loaders, }], version_groups: Vec::new(), } } async fn fetch_optifine_manifest( game_version: Option<&str>, fetch_semaphore: &FetchSemaphore, pool: &SqlitePool, ) -> crate::Result { let url = optifine_metadata_url(game_version); let entries: Vec = fetch_json(Method::GET, &url, None, None, None, fetch_semaphore, pool) .await?; Ok(optifine_manifest(entries, game_version)) } fn optifine_metadata_url(game_version: Option<&str>) -> String { game_version.map_or_else( || OPTIFINE_VERSION_LIST_URL.to_string(), |game_version| format!("{OPTIFINE_META_URL}/{game_version}"), ) } fn optifine_manifest( entries: Vec, game_version: Option<&str>, ) -> Manifest { let mut grouped = HashMap::>::new(); for entry in entries { if game_version.is_some_and(|version| version != entry.mcversion) { continue; } let id = format!("OptiFine_{}_{}", entry.type_, entry.patch); let stable = !entry.patch.to_ascii_lowercase().contains("pre"); grouped.entry(entry.mcversion.clone()).or_default().push( LoaderVersion { id, url: format!( "{OPTIFINE_META_URL}/{}/{}/{}", entry.mcversion, entry.type_, entry.patch ), stable, profile_source: LoaderProfileSource::Json, fallback_url: None, }, ); } let manifest = grouped_manifest(grouped); match game_version { Some(game_version) => scope_manifest(manifest, game_version), None => manifest, } } async fn fetch_liteloader_manifest( fetch_semaphore: &FetchSemaphore, pool: &SqlitePool, ) -> crate::Result { let metadata: serde_json::Value = fetch_json( Method::GET, LITELOADER_META_URL, None, None, None, fetch_semaphore, pool, ) .await?; liteloader_manifest(&metadata) } fn liteloader_manifest( metadata: &serde_json::Value, ) -> crate::Result { let versions = metadata .get("versions") .and_then(serde_json::Value::as_object) .ok_or_else(|| { crate::ErrorKind::OtherError( "LiteLoader metadata has no versions object".to_string(), ) .as_error() })?; let mut game_versions = Vec::new(); for (game_version, metadata) in versions { if !liteloader_supports_game_version(game_version) { continue; } let Some(entry) = liteloader_latest_entry(metadata) else { continue; }; let Some(version) = entry.get("version").and_then(|value| value.as_str()) else { continue; }; let stable = entry .get("stream") .and_then(|value| value.as_str()) .is_none_or(|stream| !stream.eq_ignore_ascii_case("snapshot")); game_versions.push(Version { id: game_version.clone(), stable: true, version_group: None, loaders: vec![LoaderVersion { id: version.to_string(), url: LITELOADER_META_URL.to_string(), stable, profile_source: LoaderProfileSource::LiteLoader, fallback_url: Some( "https://bmclapi2.bangbang93.com/maven/com/mumfrey/liteloader/versions.json" .to_string(), ), }], }); } game_versions.sort_by(|left, right| compare_versions(&right.id, &left.id)); Ok(Manifest { game_versions, version_groups: Vec::new(), }) } fn liteloader_supports_game_version(game_version: &str) -> bool { !game_version.starts_with("1.5") && !game_version.starts_with("1.6") } fn liteloader_latest_entry( game_metadata: &serde_json::Value, ) -> Option<&serde_json::Value> { game_metadata .get("artefacts") .or_else(|| game_metadata.get("snapshots"))? .get("com.mumfrey:liteloader")? .get("latest") } async fn fetch_cleanroom_manifest( fetch_semaphore: &FetchSemaphore, pool: &SqlitePool, ) -> crate::Result { let releases: Vec = fetch_json( Method::GET, CLEANROOM_RELEASES_URL, None, None, None, fetch_semaphore, pool, ) .await?; Ok(cleanroom_manifest(releases)) } fn cleanroom_manifest(releases: Vec) -> Manifest { let mut loaders = releases .into_iter() .filter(|release| !release.draft) .filter_map(|release| { let expected_name = format!("cleanroom-{}-installer.jar", release.tag_name); let asset = release .assets .into_iter() .find(|asset| asset.name == expected_name)?; Some(LoaderVersion { id: release.tag_name.clone(), url: asset.browser_download_url, stable: !release.prerelease && is_stable_version(&release.tag_name), profile_source: LoaderProfileSource::Installer, fallback_url: None, }) }) .collect::>(); loaders.sort_by(|left, right| compare_versions(&right.id, &left.id)); Manifest { game_versions: vec![Version { id: "1.12.2".to_string(), stable: true, version_group: None, loaders, }], version_groups: Vec::new(), } } fn meta_loader_versions_url(base_url: &str, game_version: &str) -> String { format!("{base_url}loader/{game_version}") } fn meta_api_manifest_for_game( game_version: &str, mut loaders: Vec<(String, bool)>, loader: &str, official_base_url: &str, fallback_format_version: usize, ) -> Manifest { loaders.sort_by(|left, right| compare_versions(&right.0, &left.0)); Manifest { game_versions: vec![Version { id: game_version.to_string(), stable: true, version_group: None, loaders: loaders .into_iter() .map(|(version, stable)| LoaderVersion { id: version.clone(), url: format!( "{official_base_url}loader/{game_version}/{version}/profile/json" ), stable, profile_source: LoaderProfileSource::Json, fallback_url: Some(fallback_profile_url( loader, fallback_format_version, &version, )), }) .collect(), }], version_groups: Vec::new(), } } fn meta_api_manifest( games: Vec, mut loaders: Vec<(String, bool)>, loader: &str, official_base_url: &str, fallback_format_version: usize, ) -> Manifest { loaders.sort_by(|left, right| compare_versions(&right.0, &left.0)); let group_id = format!("{loader}-official"); let loader_versions = loaders .into_iter() .map(|(version, stable)| LoaderVersion { id: version.clone(), url: format!( "{official_base_url}loader/{DUMMY_REPLACE_STRING}/{version}/profile/json" ), stable, profile_source: LoaderProfileSource::Json, fallback_url: Some(format!( "{}{}{}{}{}", env!("MODRINTH_LAUNCHER_META_URL"), loader, "/v", fallback_format_version, format!("/versions/{version}.json"), )), }) .collect(); Manifest { game_versions: games .into_iter() .map(|game| Version { id: game.version, stable: game.stable, version_group: Some(group_id.clone()), loaders: Vec::new(), }) .collect(), version_groups: vec![VersionGroup { id: group_id, loaders: loader_versions, }], } } async fn fetch_forge_manifest( fetch_semaphore: &FetchSemaphore, pool: &SqlitePool, ) -> crate::Result { let metadata_url = format!("{FORGE_MAVEN_URL}maven-metadata.xml"); let metadata = fetch_forge_maven_metadata(&metadata_url, fetch_semaphore, pool) .await?; let promotions = fetch_json::( Method::GET, FORGE_PROMOTIONS_URL, None, None, None, fetch_semaphore, pool, ) .await .unwrap_or_default(); Ok(forge_manifest(metadata, promotions)) } fn forge_manifest( metadata: MavenMetadata, promotions: ForgePromotions, ) -> Manifest { let mut grouped: HashMap> = HashMap::new(); for full_version in metadata.versioning.versions.values { let Some((game_version, raw_loader_version)) = full_version.split_once('-') else { continue; }; let trailing_game_version = format!("-{game_version}"); let loader_version = raw_loader_version .strip_suffix(&trailing_game_version) .unwrap_or(raw_loader_version); let recommended = promotions .promos .get(&format!("{game_version}-recommended")); let stable = recommended.is_some_and(|promoted| promoted == loader_version); add_forge_loader_version( &mut grouped, game_version, loader_version, &full_version, stable, ); } let mut manifest = grouped_manifest(grouped); for game_version in &mut manifest.game_versions { let Some(latest) = promotions .promos .get(&format!("{}-latest", game_version.id)) else { continue; }; let Some(index) = game_version .loaders .iter() .position(|loader| loader.id == *latest) else { continue; }; if index != 0 { let latest = game_version.loaders.remove(index); game_version.loaders.insert(0, latest); } } manifest } fn add_forge_loader_version( grouped: &mut HashMap>, game_version: &str, loader_version: &str, full_version: &str, stable: bool, ) { let loaders = grouped.entry(game_version.to_string()).or_default(); if let Some(existing) = loaders .iter_mut() .find(|version| version.id == loader_version) { existing.stable |= stable; return; } loaders.push(LoaderVersion { id: loader_version.to_string(), url: format!( "{FORGE_MAVEN_URL}{full_version}/forge-{full_version}-installer.jar" ), stable, profile_source: LoaderProfileSource::Installer, fallback_url: Some(fallback_profile_url("forge", 0, loader_version)), }); } async fn fetch_neoforge_manifest( fetch_semaphore: &FetchSemaphore, pool: &SqlitePool, ) -> crate::Result { let current_url = format!("{NEOFORGE_MAVEN_URL}maven-metadata.xml"); let legacy_url = format!("{NEOFORGE_LEGACY_MAVEN_URL}maven-metadata.xml"); let (current, legacy) = tokio::try_join!( fetch_maven_metadata(¤t_url, fetch_semaphore, pool), fetch_maven_metadata(&legacy_url, fetch_semaphore, pool), )?; Ok(neoforge_manifest(current, legacy)) } fn neoforge_manifest( current: MavenMetadata, legacy: MavenMetadata, ) -> Manifest { let current_release = current.versioning.release; let legacy_release = legacy.versioning.release; let mut grouped: HashMap> = HashMap::new(); for version in current.versioning.versions.values { let Some(game_version) = neoforge_game_version(&version) else { continue; }; grouped .entry(game_version) .or_default() .push(LoaderVersion { id: version.clone(), url: format!( "{NEOFORGE_MAVEN_URL}{version}/neoforge-{version}-installer.jar" ), stable: version == current_release || is_stable_version(&version), profile_source: LoaderProfileSource::Installer, fallback_url: Some(fallback_profile_url( "neo", 0, &version, )), }); } for full_version in legacy.versioning.versions.values { let Some((game_version, loader_version)) = full_version.split_once('-') else { continue; }; grouped .entry(game_version.to_string()) .or_default() .push(LoaderVersion { id: loader_version.to_string(), url: format!( "{NEOFORGE_LEGACY_MAVEN_URL}{full_version}/forge-{full_version}-installer.jar" ), stable: full_version == legacy_release || is_stable_version(loader_version), profile_source: LoaderProfileSource::Installer, fallback_url: Some(fallback_profile_url( "neo", 0, loader_version, )), }); } grouped_manifest(grouped) } fn grouped_manifest( mut grouped: HashMap>, ) -> Manifest { let mut game_versions = grouped .drain() .map(|(game_version, mut loaders)| { loaders .sort_by(|left, right| compare_versions(&right.id, &left.id)); Version { id: game_version, stable: true, version_group: None, loaders, } }) .collect::>(); game_versions.sort_by(|left, right| compare_versions(&right.id, &left.id)); Manifest { game_versions, version_groups: Vec::new(), } } fn scope_manifest(manifest: Manifest, game_version: &str) -> Manifest { let has_explicit_game_version = manifest .game_versions .iter() .any(|version| version.id == game_version); let scoped = manifest.game_versions.iter().find(|version| { version.id.replace(DUMMY_REPLACE_STRING, game_version) == game_version }); let (stable, loaders) = scoped.map_or((true, Vec::new()), |version| { let loaders = if version.id == DUMMY_REPLACE_STRING && !has_explicit_game_version { Vec::new() } else if let Some(group_id) = &version.version_group { manifest .version_groups .iter() .find(|group| group.id == *group_id) .map(|group| group.loaders.clone()) .unwrap_or_default() } else { version.loaders.clone() }; (version.stable, loaders) }); Manifest { game_versions: vec![Version { id: game_version.to_string(), stable, version_group: None, loaders, }], version_groups: Vec::new(), } } async fn fetch_maven_metadata( url: &str, fetch_semaphore: &FetchSemaphore, pool: &SqlitePool, ) -> crate::Result { let bytes = fetch(url, None, None, fetch_semaphore, pool).await?; let xml = std::str::from_utf8(&bytes).map_err(|error| { crate::ErrorKind::OtherError(format!( "Maven metadata at {url} was not UTF-8: {error}" )) .as_error() })?; quick_xml::de::from_str(xml).map_err(|error| { crate::ErrorKind::OtherError(format!( "Failed to parse Maven metadata at {url}: {error}" )) .as_error() }) } async fn fetch_forge_maven_metadata( url: &str, fetch_semaphore: &FetchSemaphore, pool: &SqlitePool, ) -> crate::Result { let bytes = fetch_official(url, None, None, fetch_semaphore, pool).await?; let xml = std::str::from_utf8(&bytes).map_err(|error| { crate::ErrorKind::OtherError(format!( "Forge Maven metadata at {url} was not UTF-8: {error}" )) .as_error() })?; quick_xml::de::from_str(xml).map_err(|error| { crate::ErrorKind::OtherError(format!( "Failed to parse Forge Maven metadata at {url}: {error}" )) .as_error() }) } fn fallback_profile_url( loader: &str, format_version: usize, version: &str, ) -> String { format!( "{}{loader}/v{format_version}/versions/{version}.json", env!("MODRINTH_LAUNCHER_META_URL") ) } fn neoforge_game_version(version: &str) -> Option { if let Some(snapshot) = version.strip_prefix("0.") { let snapshot = snapshot.rsplit_once('.')?.0; return Some(format!("1.0.{snapshot}")); } let components = version.split('.').collect::>(); let major = components.first()?.parse::().ok()?; let minor = components.get(1)?.parse::().ok()?; if major >= 26 { let patch = components.get(2)?.parse::().ok()?; if patch == 0 { Some(format!("{major}.{minor}")) } else { Some(format!("{major}.{minor}.{patch}")) } } else if minor == 0 { Some(format!("1.{major}")) } else { Some(format!("1.{major}.{minor}")) } } fn is_stable_version(version: &str) -> bool { let version = version.to_ascii_lowercase(); !["alpha", "beta", "rc", "snapshot", "pre"] .iter() .any(|marker| version.contains(marker)) } fn compare_versions(left: &str, right: &str) -> Ordering { let mut left = left.as_bytes().iter().peekable(); let mut right = right.as_bytes().iter().peekable(); loop { match (left.peek(), right.peek()) { (None, None) => return Ordering::Equal, (None, Some(_)) => return Ordering::Greater, (Some(_), None) => return Ordering::Less, (Some(left_byte), Some(right_byte)) => { let left_is_digit = left_byte.is_ascii_digit(); let right_is_digit = right_byte.is_ascii_digit(); if left_is_digit != right_is_digit { return left_byte.cmp(right_byte); } let mut left_part = Vec::new(); let mut right_part = Vec::new(); while left .peek() .is_some_and(|byte| byte.is_ascii_digit() == left_is_digit) { left_part.push(**left.peek().unwrap()); left.next(); } while right .peek() .is_some_and(|byte| byte.is_ascii_digit() == right_is_digit) { right_part.push(**right.peek().unwrap()); right.next(); } let ordering = if left_is_digit { left_part .len() .cmp(&right_part.len()) .then_with(|| left_part.cmp(&right_part)) } else { left_part.cmp(&right_part) }; if ordering != Ordering::Equal { return ordering; } } } } } pub(crate) async fn resolve_loader_profile( state: &State, game_version: &str, loader_version: &LoaderVersion, ) -> crate::Result { let primary_url = loader_version .url .replace(DUMMY_REPLACE_STRING, game_version); let primary = match loader_version.profile_source { LoaderProfileSource::Json => { fetch_json( Method::GET, &primary_url, None, None, None, &state.api_semaphore, &state.pool, ) .await } LoaderProfileSource::Installer => { resolve_installer_profile(state, &primary_url).await } LoaderProfileSource::LiteLoader => { resolve_liteloader_profile( state, game_version, &loader_version.id, &primary_url, ) .await } LoaderProfileSource::Babric => { resolve_babric_profile( state, game_version, &loader_version.id, &primary_url, ) .await } }; match primary { Ok(profile) => Ok(profile), Err(primary_error) => { let Some(fallback_url) = &loader_version.fallback_url else { return Err(primary_error); }; let fallback_url = fallback_url.replace(DUMMY_REPLACE_STRING, game_version); tracing::warn!( loader_version = loader_version.id, primary_url, fallback_url, error = %primary_error, "Official loader profile failed; using launcher-meta fallback" ); match loader_version.profile_source { LoaderProfileSource::LiteLoader => { resolve_liteloader_profile( state, game_version, &loader_version.id, &fallback_url, ) .await } LoaderProfileSource::Babric => { resolve_babric_profile( state, game_version, &loader_version.id, &fallback_url, ) .await } _ => { fetch_json( Method::GET, &fallback_url, None, None, None, &state.api_semaphore, &state.pool, ) .await } } } } } async fn resolve_babric_profile( state: &State, game_version: &str, loader_version: &str, profile_url: &str, ) -> crate::Result { if game_version != BABRIC_GAME_VERSION { return Err(crate::ErrorKind::InputError(format!( "Babric does not support Minecraft {game_version}" )) .into()); } match fetch_json::( Method::GET, profile_url, None, None, None, &state.api_semaphore, &state.pool, ) .await { Ok(profile) => Ok(profile), Err(error) => { tracing::warn!( game_version, loader_version, error = %error, "Babric profile endpoint is unavailable; synthesizing the profile from Glass Maven" ); build_babric_profile(state, game_version, loader_version).await } } } async fn build_babric_profile( state: &State, game_version: &str, loader_version: &str, ) -> crate::Result { let loader_metadata_url = format!( "{BABRIC_LOADER_METADATA_URL}{loader_version}/fabric-loader-{loader_version}.json" ); let polyfill = fetch_json::( Method::GET, BABRIC_POLYFILL_VERSION_URL, None, None, None, &state.api_semaphore, &state.pool, ) .await?; let launcher_metadata = fetch_json::( Method::GET, &loader_metadata_url, None, None, None, &state.api_semaphore, &state.pool, ) .await?; let mut libraries = launcher_metadata.libraries.common; libraries.extend(launcher_metadata.libraries.client); libraries.push(babric_library( "babric:intermediary-upstream:b1.7.3", BABRIC_MAVEN_URL, )); libraries.push(babric_library( &format!("babric:fabric-loader:{loader_version}"), BABRIC_MAVEN_URL, )); libraries.extend(babric_logging_libraries()); libraries.extend(babric_lwjgl_libraries(&polyfill)?); let minecraft_arguments = polyfill .get("minecraftArguments") .and_then(serde_json::Value::as_str) .unwrap_or("${auth_player_name} ${auth_session} --gameDir ${game_directory} --assetsDir ${game_assets}"); let game_arguments = minecraft_arguments .split_whitespace() .map(|argument| Argument::Normal(argument.to_string())) .collect::>(); let jvm_arguments = vec![ Argument::Normal( "-DFabricMcEmu= net.minecraft.client.main.Main ".to_string(), ), Argument::Normal("-cp".to_string()), Argument::Normal("${classpath}".to_string()), Argument::Normal( "-Djava.library.path=${natives_directory}".to_string(), ), ]; let now = chrono::Utc::now(); Ok(PartialVersionInfo { id: format!("fabric-loader-{loader_version}-{game_version}"), inherits_from: game_version.to_string(), release_time: now, time: now, main_class: Some(launcher_metadata.main_class.client), minecraft_arguments: None, arguments: Some(HashMap::from([ (ArgumentType::Game, game_arguments), (ArgumentType::Jvm, jvm_arguments), ])), libraries, java_version: Some(JavaVersion { component: "jre-legacy".to_string(), major_version: launcher_metadata.min_java_version.unwrap_or(8), }), type_: VersionType::Release, data: None, processors: None, }) } fn babric_library(name: &str, url: &str) -> Library { Library { downloads: None, extract: None, name: name.to_string(), url: Some(url.to_string()), natives: None, rules: None, checksums: None, include_in_classpath: true, downloadable: true, } } fn babric_logging_libraries() -> Vec { [ ("babric:log4j-config:1.0.0", BABRIC_MAVEN_URL), ( "net.minecrell:terminalconsoleappender:1.2.0", "https://repo1.maven.org/maven2/", ), ( "org.slf4j:slf4j-api:1.8.0-beta4", "https://libraries.minecraft.net/", ), ( "org.apache.logging.log4j:log4j-slf4j18-impl:2.16.0", "https://libraries.minecraft.net/", ), ( "org.apache.logging.log4j:log4j-api:2.16.0", "https://libraries.minecraft.net/", ), ( "org.apache.logging.log4j:log4j-core:2.16.0", "https://libraries.minecraft.net/", ), ( "com.google.code.gson:gson:2.8.9", "https://libraries.minecraft.net/", ), ( "com.google.guava:guava:31.0.1-jre", "https://libraries.minecraft.net/", ), ( "com.google.guava:failureaccess:1.0.1", "https://libraries.minecraft.net/", ), ( "org.apache.commons:commons-lang3:3.12.0", "https://libraries.minecraft.net/", ), ( "commons-io:commons-io:2.11.0", "https://libraries.minecraft.net/", ), ( "commons-codec:commons-codec:1.15", "https://libraries.minecraft.net/", ), ] .into_iter() .map(|(name, url)| babric_library(name, url)) .collect() } fn babric_lwjgl_libraries( polyfill: &serde_json::Value, ) -> crate::Result> { let Some(entries) = polyfill .get("libraries") .and_then(serde_json::Value::as_array) else { return Ok(Vec::new()); }; entries .iter() .filter(|library| { let Some(name) = library.get("name").and_then(serde_json::Value::as_str) else { return false; }; name.contains("lwjgl") && name.contains("-babric.") }) .cloned() .map(serde_json::from_value) .collect::, _>>() .map_err(Into::into) } async fn resolve_liteloader_profile( state: &State, game_version: &str, loader_version: &str, url: &str, ) -> crate::Result { let metadata: serde_json::Value = fetch_json( Method::GET, url, None, None, None, &state.api_semaphore, &state.pool, ) .await?; build_liteloader_profile(&metadata, game_version, loader_version) } fn build_liteloader_profile( metadata: &serde_json::Value, game_version: &str, loader_version: &str, ) -> crate::Result { let game_metadata = metadata .get("versions") .and_then(|versions| versions.get(game_version)) .ok_or_else(|| { crate::ErrorKind::InputError(format!( "LiteLoader does not support Minecraft {game_version}" )) .as_error() })?; let entry = liteloader_latest_entry(game_metadata).ok_or_else(|| { crate::ErrorKind::OtherError(format!( "LiteLoader metadata for {game_version} has no latest build" )) .as_error() })?; let actual_version = entry .get("version") .and_then(serde_json::Value::as_str) .ok_or_else(|| { crate::ErrorKind::OtherError( "LiteLoader metadata has no version".to_string(), ) .as_error() })?; if actual_version != loader_version { return Err(crate::ErrorKind::InputError(format!( "LiteLoader version {loader_version} is not available for Minecraft {game_version}" )) .into()); } let tweak_class = entry .get("tweakClass") .and_then(serde_json::Value::as_str) .ok_or_else(|| { crate::ErrorKind::OtherError( "LiteLoader metadata has no tweakClass".to_string(), ) .as_error() })?; let mut libraries = entry .get("libraries") .cloned() .map(serde_json::from_value::>) .transpose()? .unwrap_or_default(); libraries.push(Library { downloads: None, extract: None, name: format!("com.mumfrey:liteloader:{actual_version}"), url: Some("https://dl.liteloader.com/versions/".to_string()), natives: None, rules: None, checksums: None, include_in_classpath: true, downloadable: true, }); let timestamp = entry .get("timestamp") .and_then(|value| { value .as_i64() .or_else(|| value.as_str()?.parse::().ok()) }) .unwrap_or(0); let time = chrono::DateTime::from_timestamp(timestamp, 0) .unwrap_or(chrono::DateTime::UNIX_EPOCH); Ok(PartialVersionInfo { id: format!("{game_version}-LiteLoader"), inherits_from: game_version.to_string(), release_time: time, time, main_class: Some("net.minecraft.launchwrapper.Launch".to_string()), minecraft_arguments: None, arguments: Some(HashMap::from([( daedalus::minecraft::ArgumentType::Game, vec![ daedalus::minecraft::Argument::Normal( "--tweakClass".to_string(), ), daedalus::minecraft::Argument::Normal(tweak_class.to_string()), ], )])), libraries, java_version: Some(JavaVersion { component: "jre-legacy".to_string(), major_version: 8, }), type_: daedalus::minecraft::VersionType::Release, data: None, processors: None, }) } async fn resolve_installer_profile( state: &State, installer_url: &str, ) -> crate::Result { let bytes = fetch(installer_url, None, None, &state.api_semaphore, &state.pool) .await?; let installer_url = installer_url.to_string(); let parsed = tokio::task::spawn_blocking(move || { parse_installer(bytes.to_vec(), &installer_url) }) .await??; persist_installer_artifacts(state, &parsed.artifacts).await?; Ok(parsed.profile) } pub(crate) async fn ensure_installer_artifacts( state: &State, version: &daedalus::minecraft::VersionInfo, ) -> crate::Result<()> { let Some(data) = &version.data else { return Ok(()); }; let Some(installer_url) = data.get(INSTALLER_URL_KEY) else { return Ok(()); }; let entries = data .iter() .filter(|(key, _)| key.starts_with(INSTALLER_ENTRY_KEY_PREFIX)) .filter_map(|(_, entry)| entry.client.split_once('|')) .map(|(coordinate, archive_path)| { (coordinate.to_string(), archive_path.to_string()) }) .collect::>(); let mut missing = Vec::new(); for (coordinate, archive_path) in entries { let relative = daedalus::get_path_from_artifact(&coordinate)?; if !state.directories.libraries_dir().join(relative).exists() { missing.push((coordinate, archive_path)); } } if missing.is_empty() { return Ok(()); } let bytes = fetch( &installer_url.client, None, None, &state.api_semaphore, &state.pool, ) .await?; let artifacts = tokio::task::spawn_blocking(move || { extract_installer_artifacts(bytes.to_vec(), &missing) }) .await??; persist_installer_artifacts(state, &artifacts).await } fn parse_installer( bytes: Vec, installer_url: &str, ) -> crate::Result { let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|error| { crate::ErrorKind::OtherError(format!( "Failed to open loader installer: {error}" )) .as_error() })?; let profile_json = read_zip_entry(&mut archive, "install_profile.json")?; let profile_value: serde_json::Value = serde_json::from_slice(&profile_json)?; let version_json = if let Ok(version_json) = read_zip_entry(&mut archive, "version.json") { version_json } else if let Some(path) = profile_value.get("json").and_then(|value| value.as_str()) { read_zip_entry(&mut archive, path.trim_start_matches('/'))? } else if let Some(version_info) = profile_value.get("versionInfo") { serde_json::to_vec(version_info)? } else { return Err(crate::ErrorKind::OtherError( "Loader installer contains no version.json, profile json, or versionInfo" .to_string(), ) .as_error()); }; let mut installer: InstallerProfile = serde_json::from_value(profile_value)?; let mut version_value: serde_json::Value = serde_json::from_slice(&version_json)?; if version_value.get("inheritsFrom").is_none() { let minecraft = installer.minecraft.clone().ok_or_else(|| { crate::ErrorKind::OtherError( "Loader installer version has no inheritsFrom or minecraft version" .to_string(), ) .as_error() })?; version_value .as_object_mut() .ok_or_else(|| { crate::ErrorKind::OtherError( "Loader installer version is not a JSON object".to_string(), ) .as_error() })? .insert( "inheritsFrom".to_string(), serde_json::Value::String(minecraft), ); } let mut partial: PartialVersionInfo = serde_json::from_value(version_value)?; for library in &mut installer.libraries { library.include_in_classpath = false; } let has_processors = !installer.processors.is_empty(); for library in &mut partial.libraries { let library_name = library.name.clone(); if let Some(artifact) = library .downloads .as_mut() .and_then(|downloads| downloads.artifact.as_mut()) && artifact.url.is_empty() { if !has_processors && let Some(full_version) = library_name.strip_prefix("net.minecraftforge:forge:") { artifact.url = format!( "{FORGE_MAVEN_URL}{full_version}/forge-{full_version}-universal.jar" ); library.downloadable = true; } else { library.downloadable = false; } } } let existing = partial .libraries .iter() .map(|library| library.name.clone()) .collect::>(); partial.libraries.extend( installer .libraries .into_iter() .filter(|library| !existing.contains(&library.name)), ); partial.processors = Some(installer.processors); let requests = installer .data .iter() .flat_map(|(key, entry)| { [("client", &entry.client), ("server", &entry.server)] .into_iter() .filter_map(move |(side, value)| { value .strip_prefix('/') .map(|path| (key.clone(), side, path.to_string())) }) }) .collect::>(); let mut artifacts = Vec::new(); for (index, (key, side, archive_path)) in requests.into_iter().enumerate() { let artifact_data = read_zip_entry(&mut archive, &archive_path)?; let extension = std::path::Path::new(&archive_path) .extension() .and_then(|extension| extension.to_str()) .unwrap_or("bin"); let coordinate = format!( "com.axolotl.loader-installer:embedded:{}:{}-{}@{}", sanitize_coordinate_part(&partial.id), sanitize_coordinate_part(&key), side, sanitize_coordinate_part(extension), ); let entry = installer.data.get_mut(&key).unwrap(); let replacement = format!("[{coordinate}]"); if side == "client" { entry.client = replacement; } else { entry.server = replacement; } installer.data.insert( format!("{INSTALLER_ENTRY_KEY_PREFIX}{index}"), SidedDataEntry { client: format!("{coordinate}|{archive_path}"), server: format!("{coordinate}|{archive_path}"), }, ); partial.libraries.push(local_library(coordinate.clone())); artifacts.push(InstallerArtifact { coordinate, data: artifact_data, }); } if let Some(install) = installer.install { let archive_path = install.file_path.trim_start_matches('/'); let artifact_data = read_zip_entry(&mut archive, archive_path)?; let index = installer.data.len(); installer.data.insert( format!("{INSTALLER_ENTRY_KEY_PREFIX}{index}"), SidedDataEntry { client: format!("{}|{archive_path}", install.path), server: format!("{}|{archive_path}", install.path), }, ); if !partial .libraries .iter() .any(|library| library.name == install.path) { partial.libraries.push(local_library(install.path.clone())); } artifacts.push(InstallerArtifact { coordinate: install.path, data: artifact_data, }); } let embedded_maven_entries = archive .file_names() .filter_map(|path| { coordinate_from_maven_path(path) .map(|coordinate| (coordinate, path.to_string())) }) .collect::>(); for (coordinate, archive_path) in embedded_maven_entries { if artifacts .iter() .any(|artifact| artifact.coordinate == coordinate) { continue; } let artifact_data = read_zip_entry(&mut archive, &archive_path)?; let index = installer.data.len(); installer.data.insert( format!("{INSTALLER_ENTRY_KEY_PREFIX}{index}"), SidedDataEntry { client: format!("{coordinate}|{archive_path}"), server: format!("{coordinate}|{archive_path}"), }, ); artifacts.push(InstallerArtifact { coordinate, data: artifact_data, }); } installer.data.insert( INSTALLER_URL_KEY.to_string(), SidedDataEntry { client: installer_url.to_string(), server: installer_url.to_string(), }, ); partial.data = Some(installer.data); Ok(ParsedInstaller { profile: partial, artifacts, }) } fn coordinate_from_maven_path(path: &str) -> Option { let path = path.strip_prefix("maven/")?; let components = path.split('/').collect::>(); if components.len() < 4 { return None; } let file_name = components.last()?; let version = components.get(components.len() - 2)?; let artifact = components.get(components.len() - 3)?; let group = components[..components.len() - 3].join("."); let prefix = format!("{artifact}-{version}"); let suffix = file_name.strip_prefix(&prefix)?; let (classifier, extension) = suffix.rsplit_once('.')?; let classifier = classifier .strip_prefix('-') .filter(|value| !value.is_empty()); Some(match classifier { Some(classifier) => { format!("{group}:{artifact}:{version}:{classifier}@{extension}") } None if extension == "jar" => format!("{group}:{artifact}:{version}"), None => format!("{group}:{artifact}:{version}@{extension}"), }) } fn extract_installer_artifacts( bytes: Vec, entries: &[(String, String)], ) -> crate::Result> { let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|error| { crate::ErrorKind::OtherError(format!( "Failed to open cached loader installer: {error}" )) .as_error() })?; entries .iter() .map(|(coordinate, archive_path)| { Ok(InstallerArtifact { coordinate: coordinate.clone(), data: read_zip_entry(&mut archive, archive_path)?, }) }) .collect() } fn read_zip_entry( archive: &mut zip::ZipArchive, path: &str, ) -> crate::Result> { let mut entry = archive.by_name(path).map_err(|error| { crate::ErrorKind::OtherError(format!( "Loader installer is missing {path}: {error}" )) .as_error() })?; let mut data = Vec::new(); entry.read_to_end(&mut data)?; Ok(data) } async fn persist_installer_artifacts( state: &State, artifacts: &[InstallerArtifact], ) -> crate::Result<()> { for artifact in artifacts { let relative = daedalus::get_path_from_artifact(&artifact.coordinate)?; let path = state.directories.libraries_dir().join(relative); if let Some(parent) = path.parent() { io::create_dir_all(parent).await?; } io::write(path, &artifact.data).await?; } Ok(()) } fn local_library(name: String) -> Library { Library { downloads: None, extract: None, name, url: None, natives: None, rules: None, checksums: None, include_in_classpath: false, downloadable: false, } } fn sanitize_coordinate_part(value: &str) -> String { value .chars() .map(|character| { if character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') { character } else { '_' } }) .collect() } #[cfg(test)] mod tests { use super::*; use std::io::Write; use zip::write::SimpleFileOptions; fn maven_metadata(release: &str, versions: &[&str]) -> MavenMetadata { MavenMetadata { versioning: MavenVersioning { release: release.to_string(), versions: MavenVersions { values: versions .iter() .map(|version| version.to_string()) .collect(), }, }, } } fn forge_loaders<'a>( manifest: &'a Manifest, game_version: &str, ) -> &'a [LoaderVersion] { &manifest .game_versions .iter() .find(|version| version.id == game_version) .unwrap_or_else(|| { panic!("missing Forge support for {game_version}") }) .loaders } #[test] fn forge_metadata_groups_versions_and_uses_installer_profiles() { let manifest = forge_manifest( maven_metadata( "1.21.5-55.1.13", &["1.21.5-55.1.9", "1.21.5-55.1.13"], ), ForgePromotions { promos: HashMap::from([ ("1.21.5-latest".to_string(), "55.1.13".to_string()), ("1.21.5-recommended".to_string(), "55.1.9".to_string()), ]), }, ); let loaders = &manifest.game_versions[0].loaders; assert_eq!(loaders[0].id, "55.1.13"); assert_eq!(loaders[0].profile_source, LoaderProfileSource::Installer); assert!(!loaders[0].stable); assert!(loaders[1].stable); assert!( loaders[0].url.ends_with( "/1.21.5-55.1.13/forge-1.21.5-55.1.13-installer.jar" ) ); } #[test] fn forge_maven_metadata_parses_modern_version_coordinates() { let metadata: MavenMetadata = quick_xml::de::from_str( r#" 26.2-65.1.0 1.18.2-40.3.12 1.19-41.1.0 1.20.1-47.4.22 1.21.1-52.1.16 26.1-62.0.9 26.2-65.1.0 "#, ) .unwrap(); let manifest = forge_manifest(metadata, ForgePromotions::default()); for (game_version, loader_prefix) in [ ("1.18.2", "40."), ("1.19", "41."), ("1.20.1", "47."), ("1.21.1", "52."), ("26.1", "62."), ("26.2", "65."), ] { assert!( forge_loaders(&manifest, game_version) .iter() .any(|loader| loader.id.starts_with(loader_prefix)), "missing Forge {loader_prefix} for Minecraft {game_version}" ); } } #[test] fn forge_preserves_complete_maven_catalog_and_maps_promotions() { let metadata: MavenMetadata = quick_xml::de::from_str( r#" 26.2-65.1.1 26.2-65.1.1 26.2-65.1.0 26.2-65.0.9 26.2-65.0.8 26.2-65.0.1 26.2-65.0.0 26.1-62.0.9 "#, ) .unwrap(); let manifest = forge_manifest( metadata, ForgePromotions { promos: HashMap::from([ ("26.2-latest".to_string(), "65.1.1".to_string()), ("26.2-recommended".to_string(), "65.1.0".to_string()), ]), }, ); let loaders = forge_loaders(&manifest, "26.2"); assert_eq!( loaders .iter() .map(|loader| loader.id.as_str()) .collect::>(), ["65.1.1", "65.1.0", "65.0.9", "65.0.8", "65.0.1", "65.0.0"] ); assert!(!loaders[0].stable); assert!(loaders[1].stable); assert!(loaders[2..].iter().all(|loader| !loader.stable)); assert!( forge_loaders(&manifest, "26.1") .iter() .all(|loader| !loader.id.starts_with("65.")) ); } #[test] fn forge_promotions_do_not_create_catalog_entries() { let manifest = forge_manifest( maven_metadata("26.2-65.1.0", &["26.2-65.1.0"]), ForgePromotions { promos: HashMap::from([( "26.2-latest".to_string(), "65.1.1".to_string(), )]), }, ); assert_eq!(forge_loaders(&manifest, "26.2")[0].id, "65.1.0"); } #[test] fn forge_scoped_metadata_keeps_empty_and_supported_versions_isolated() { let full = forge_manifest( maven_metadata( "1.20.1-47.4.22", &["1.20.1-47.4.21", "1.20.1-47.4.22", "1.21.5-55.1.13"], ), ForgePromotions::default(), ); let forge_262 = scope_manifest(full.clone(), "26.2"); let forge_1201 = scope_manifest(full.clone(), "1.20.1"); let forge_262_again = scope_manifest(full, "26.2"); assert!(forge_262.game_versions[0].loaders.is_empty()); assert_eq!( forge_1201.game_versions[0] .loaders .iter() .map(|loader| loader.id.as_str()) .collect::>(), ["47.4.22", "47.4.21"] ); assert!(forge_262_again.game_versions[0].loaders.is_empty()); assert!(validate_scoped_manifest(forge_262, "26.2").is_ok()); } #[test] fn forge_scoped_metadata_preserves_complete_catalog_across_switches() { let full = forge_manifest( maven_metadata( "26.2-65.1.1", &[ "26.2-65.1.1", "26.2-65.1.0", "26.2-65.0.9", "1.20.1-47.4.22", "1.20.1-47.4.21", ], ), ForgePromotions::default(), ); let forge_262 = scope_manifest(full.clone(), "26.2"); let forge_1201 = scope_manifest(full.clone(), "1.20.1"); let forge_262_again = scope_manifest(full, "26.2"); let ids = |manifest: &Manifest| { manifest.game_versions[0] .loaders .iter() .map(|loader| loader.id.clone()) .collect::>() }; assert_eq!(ids(&forge_262), ["65.1.1", "65.1.0", "65.0.9"]); assert_eq!(ids(&forge_1201), ["47.4.22", "47.4.21"]); assert_eq!(ids(&forge_262_again), ids(&forge_262)); } #[test] fn meta_api_manifest_uses_official_profiles_and_fallbacks() { let manifest = meta_api_manifest( vec![MetaGameVersion { version: "1.21.1".to_string(), stable: true, }], vec![("0.16.9".to_string(), false), ("0.16.14".to_string(), true)], "fabric", FABRIC_META_URL, 0, ); let version = &manifest.game_versions[0]; let loaders = &manifest.version_groups[0].loaders; assert_eq!(version.version_group.as_deref(), Some("fabric-official")); assert_eq!(loaders[0].id, "0.16.14"); assert!( loaders[0] .url .contains("${modrinth.gameVersion}/0.16.14/profile/json") ); assert!( loaders[0] .fallback_url .as_ref() .unwrap() .ends_with("fabric/v0/versions/0.16.14.json") ); } #[test] fn scoped_meta_manifest_binds_profile_and_versions_to_requested_game() { assert_eq!( meta_loader_versions_url(FABRIC_META_URL, "1.20.1"), "https://meta.fabricmc.net/v2/versions/loader/1.20.1" ); assert_eq!( meta_loader_versions_url(QUILT_META_URL, "26.2"), "https://meta.quiltmc.org/v3/versions/loader/26.2" ); let manifest = meta_api_manifest_for_game( "1.20.1", vec![("0.16.9".to_string(), false), ("0.16.14".to_string(), true)], "fabric", FABRIC_META_URL, 0, ); let game = &manifest.game_versions[0]; assert_eq!(game.id, "1.20.1"); assert_eq!(game.loaders[0].id, "0.16.14"); assert!( game.loaders[0] .url .contains("loader/1.20.1/0.16.14/profile/json") ); assert!(!game.loaders[0].url.contains(DUMMY_REPLACE_STRING)); } #[test] fn neoforge_scoped_metadata_does_not_mix_build_series() { let full = neoforge_manifest( maven_metadata("21.2.10", &["21.1.200", "21.1.201", "21.2.10"]), maven_metadata("", &[]), ); let mc_1211 = scope_manifest(full.clone(), "1.21.1"); let mc_1212 = scope_manifest(full.clone(), "1.21.2"); let mc_1211_again = scope_manifest(full, "1.21.1"); assert!( mc_1211.game_versions[0] .loaders .iter() .all(|loader| loader.id.starts_with("21.1.")) ); assert!( mc_1212.game_versions[0] .loaders .iter() .all(|loader| loader.id.starts_with("21.2.")) ); assert_eq!( mc_1211.game_versions[0] .loaders .iter() .map(|loader| loader.id.as_str()) .collect::>(), mc_1211_again.game_versions[0] .loaders .iter() .map(|loader| loader.id.as_str()) .collect::>() ); } #[test] fn neoforge_versions_map_to_minecraft_versions() { assert_eq!(neoforge_game_version("20.2.93"), Some("1.20.2".into())); assert_eq!(neoforge_game_version("21.0.167"), Some("1.21".into())); assert_eq!(neoforge_game_version("21.11.45"), Some("1.21.11".into())); assert_eq!( neoforge_game_version("26.1.0.19-beta"), Some("26.1".into()) ); assert_eq!(neoforge_game_version("26.1.2.95"), Some("26.1.2".into())); assert_eq!( neoforge_game_version("0.25w14craftmine.5-beta"), Some("1.0.25w14craftmine".into()) ); } #[test] fn installer_parser_merges_profile_and_embedded_artifacts() { let cursor = Cursor::new(Vec::new()); let mut archive = zip::ZipWriter::new(cursor); let options = SimpleFileOptions::default(); archive.start_file("version.json", options).unwrap(); archive .write_all( br#"{ "id":"1.21.5-forge-55.1.13", "inheritsFrom":"1.21.5", "releaseTime":"2026-08-15T20:37:45+00:00", "time":"2026-08-15T20:37:45+00:00", "mainClass":"example.Main", "libraries":[{ "name":"example:generated:1.0", "downloads":{"artifact":{"path":"example/generated/1.0/generated-1.0.jar","sha1":"abc","size":1,"url":""}} }], "type":"release" }"#, ) .unwrap(); archive.start_file("install_profile.json", options).unwrap(); archive .write_all( br#"{ "data":{"BINPATCH":{"client":"/data/client.lzma","server":"/data/server.lzma"}}, "processors":[{"jar":"example:processor:1.0","classpath":[],"args":["{BINPATCH}"]}], "libraries":[{"name":"example:processor:1.0"}] }"#, ) .unwrap(); archive.start_file("data/client.lzma", options).unwrap(); archive.write_all(b"client-patch").unwrap(); archive.start_file("data/server.lzma", options).unwrap(); archive.write_all(b"server-patch").unwrap(); let bytes = archive.finish().unwrap().into_inner(); let parsed = parse_installer(bytes, "https://example.com/installer.jar") .unwrap(); assert_eq!(parsed.artifacts.len(), 2); assert_eq!(parsed.profile.processors.as_ref().unwrap().len(), 1); assert!(parsed.profile.libraries.iter().any(|library| { library.name == "example:processor:1.0" && !library.include_in_classpath })); assert!(parsed.profile.libraries.iter().any(|library| { library.name == "example:generated:1.0" && !library.downloadable })); let data = parsed.profile.data.unwrap(); assert!(data["BINPATCH"].client.starts_with("[com.axolotl.")); assert_eq!( data[INSTALLER_URL_KEY].client, "https://example.com/installer.jar" ); } fn minimal_installer_version(id: &str) -> serde_json::Value { serde_json::json!({ "id": id, "inheritsFrom": "1.12.2", "releaseTime": "2017-09-18T08:39:46+00:00", "time": "2017-09-18T08:39:46+00:00", "mainClass": "net.minecraft.launchwrapper.Launch", "libraries": [], "type": "release" }) } fn installer_fixture( version_path: Option<&str>, profile: serde_json::Value, ) -> Vec { let cursor = Cursor::new(Vec::new()); let mut archive = zip::ZipWriter::new(cursor); let options = SimpleFileOptions::default(); if let Some(version_path) = version_path { archive.start_file(version_path, options).unwrap(); archive .write_all( serde_json::to_string(&minimal_installer_version( version_path, )) .unwrap() .as_bytes(), ) .unwrap(); } archive.start_file("install_profile.json", options).unwrap(); archive .write_all(serde_json::to_string(&profile).unwrap().as_bytes()) .unwrap(); archive.finish().unwrap().into_inner() } #[test] fn babric_manifest_is_scoped_to_beta_1_7_3_and_uses_babric_profiles() { let manifest = babric_manifest("b1.7.3", vec!["0.15.6-babric.2".to_string()]); assert_eq!(manifest.game_versions[0].id, "b1.7.3"); let loader = &manifest.game_versions[0].loaders[0]; assert!(loader.url.contains("meta.babric.dev")); assert!( loader .url .contains("loader/b1.7.3/0.15.6-babric.2/profile/json") ); assert_eq!(loader.profile_source, LoaderProfileSource::Babric); assert!(loader.fallback_url.is_none()); let profile: PartialVersionInfo = serde_json::from_value(serde_json::json!({ "id": "babric-loader-0.15.6-babric.2-b1.7.3", "inheritsFrom": "b1.7.3", "releaseTime": "2026-08-23T00:00:00Z", "time": "2026-08-23T00:00:00Z", "type": "release", "mainClass": "net.fabricmc.loader.impl.launch.knot.KnotClient", "libraries": [ {"name": "babric:intermediary:b1.7.3"}, {"name": "babric:fabric-loader:0.15.6-babric.2"} ], "arguments": {"game": ["--demo"]} })) .unwrap(); assert_eq!( profile.main_class.as_deref(), Some("net.fabricmc.loader.impl.launch.knot.KnotClient") ); assert_eq!(profile.inherits_from, "b1.7.3"); assert!( profile.libraries.iter().any(|library| { library.name == "babric:intermediary:b1.7.3" }) ); assert_eq!( serde_json::to_value(&profile.arguments).unwrap()["game"], serde_json::json!(["--demo"]) ); } #[test] fn babric_uses_the_glass_maven_fallback_when_metadata_is_unavailable() { let manifest = babric_manifest( BABRIC_GAME_VERSION, vec![BABRIC_FALLBACK_LOADER_VERSION.to_string()], ); let loader = &manifest.game_versions[0].loaders[0]; assert_eq!(manifest.game_versions[0].id, BABRIC_GAME_VERSION); assert_eq!(loader.id, BABRIC_FALLBACK_LOADER_VERSION); assert!(loader.stable); assert!( loader .url .contains("loader/b1.7.3/0.15.6-babric.2/profile/json") ); let unsupported = babric_unsupported_manifest("1.7.10"); assert_eq!(unsupported.game_versions[0].id, "1.7.10"); assert!(unsupported.game_versions[0].loaders.is_empty()); } #[test] fn babric_profile_libraries_copy_only_babric_lwjgl_from_polyfill() { let libraries = babric_lwjgl_libraries(&serde_json::json!({ "libraries": [ {"name": "org.lwjgl.lwjgl:lwjgl:2.9.4-babric.1"}, {"name": "org.lwjgl.lwjgl:lwjgl_util:2.9.4-babric.1"}, {"name": "org.lwjgl.lwjgl:lwjgl:2.9.0"}, {"name": "net.java.jinput:jinput:2.0.5"} ] })) .unwrap(); assert_eq!(libraries.len(), 2); assert!(libraries.iter().all(|library| { library.name.contains("lwjgl") && library.name.contains("-babric.") })); } #[test] fn installer_parser_accepts_mid_legacy_profile_json_path() { let bytes = installer_fixture( Some("install/1.8.9.json"), serde_json::json!({ "json": "/install/1.8.9.json", "libraries": [] }), ); let parsed = parse_installer(bytes, "https://example.com/forge.jar") .expect("mid-legacy installer"); assert_eq!(parsed.profile.id, "install/1.8.9.json"); } #[test] fn installer_parser_accepts_old_inline_version_info() { let version_info = minimal_installer_version("1.7.10-Forge"); let bytes = installer_fixture( None, serde_json::json!({ "versionInfo": version_info, "libraries": [] }), ); let parsed = parse_installer(bytes, "https://example.com/forge.jar") .expect("old inline installer"); assert_eq!(parsed.profile.id, "1.7.10-Forge"); } #[test] fn installer_parser_accepts_legacy_universal_artifact() { let cursor = Cursor::new(Vec::new()); let mut archive = zip::ZipWriter::new(cursor); let options = SimpleFileOptions::default(); archive.start_file("install_profile.json", options).unwrap(); archive .write_all( serde_json::json!({ "versionInfo": minimal_installer_version("1.7.10-Forge"), "install": { "path": "net.minecraftforge:forge:1.7.10-10.13.4.1614-1.7.10", "filePath": "/forge-universal.jar" } }) .to_string() .as_bytes(), ) .unwrap(); archive.start_file("forge-universal.jar", options).unwrap(); archive.write_all(b"legacy universal artifact").unwrap(); let parsed = parse_installer( archive.finish().unwrap().into_inner(), "https://example.com/forge.jar", ) .unwrap(); assert!(parsed.profile.libraries.iter().any(|library| { library.name == "net.minecraftforge:forge:1.7.10-10.13.4.1614-1.7.10" && !library.downloadable })); assert_eq!(parsed.artifacts.len(), 1); } #[test] fn coordinate_from_embedded_maven_path_preserves_classifier_and_extension() { assert_eq!( coordinate_from_maven_path( "maven/com/example/tool/1.2.3/tool-1.2.3-all.jar" ), Some("com.example:tool:1.2.3:all@jar".to_string()) ); assert_eq!( coordinate_from_maven_path( "maven/com/example/tool/1.2.3/tool-1.2.3.zip" ), Some("com.example:tool:1.2.3@zip".to_string()) ); assert_eq!( coordinate_from_maven_path( "maven/com/example/tool/1.2.3/not-an-artifact.jar" ), None ); } #[test] fn installer_profile_uses_profile_minecraft_when_version_has_no_inheritance() { let cursor = Cursor::new(Vec::new()); let mut archive = zip::ZipWriter::new(cursor); let options = SimpleFileOptions::default(); archive.start_file("version.json", options).unwrap(); archive .write_all( serde_json::json!({ "id": "1.12.2-Cleanroom-test", "releaseTime": "2026-08-15T20:37:45+00:00", "time": "2026-08-15T20:37:45+00:00", "mainClass": "top.outlands.foundation.boot.Foundation", "javaVersion": {"component": "java-runtime-epsilon", "majorVersion": 25}, "libraries": [], "type": "release" }) .to_string() .as_bytes(), ) .unwrap(); archive.start_file("install_profile.json", options).unwrap(); archive .write_all(br#"{"minecraft":"1.12.2","json":"/version.json"}"#) .unwrap(); let parsed = parse_installer( archive.finish().unwrap().into_inner(), "https://example.com/cleanroom.jar", ) .unwrap(); assert_eq!(parsed.profile.inherits_from, "1.12.2"); assert_eq!( parsed .profile .java_version .as_ref() .map(|java| java.major_version), Some(25) ); } #[test] fn provider_fixtures_filter_and_parse_new_loaders() { assert_eq!( optifine_metadata_url(None), "https://bmclapi2.bangbang93.com/optifine/versionList" ); assert_eq!( optifine_metadata_url(Some("1.20.1")), "https://bmclapi2.bangbang93.com/optifine/1.20.1" ); let optifine = optifine_manifest( vec![ OptiFineMetadata { mcversion: "1.12.2".to_string(), type_: "HD_U".to_string(), patch: "G5".to_string(), }, OptiFineMetadata { mcversion: "1.20.1".to_string(), type_: "HD_U".to_string(), patch: "I6_pre1".to_string(), }, ], Some("1.12.2"), ); assert_eq!(optifine.game_versions.len(), 1); assert_eq!(optifine.game_versions[0].loaders[0].id, "OptiFine_HD_U_G5"); let unsupported_optifine = optifine_manifest(Vec::new(), Some("26.2")); assert_eq!(unsupported_optifine.game_versions.len(), 1); assert_eq!(unsupported_optifine.game_versions[0].id, "26.2"); assert!(unsupported_optifine.game_versions[0].loaders.is_empty()); assert!(validate_scoped_manifest(unsupported_optifine, "26.2").is_ok()); let lite_metadata = serde_json::json!({ "versions": { "1.6.4": { "artefacts": { "com.mumfrey:liteloader": { "latest": { "tweakClass": "com.mumfrey.liteloader.launch.LiteLoaderTweaker", "libraries": [{"name": "net.minecraft:launchwrapper:1.8"}], "version": "1.6.4_01", "stream": "RELEASE", "timestamp": "1380567800" } } } }, "1.12.2": { "snapshots": { "com.mumfrey:liteloader": { "latest": { "tweakClass": "com.mumfrey.liteloader.launch.LiteLoaderTweaker", "libraries": [{"name": "net.minecraft:launchwrapper:1.12"}], "version": "1.12.2-SNAPSHOT", "stream": "SNAPSHOT", "timestamp": "1511880271" } } } } } }); let lite = liteloader_manifest(&lite_metadata).unwrap(); assert_eq!(lite.game_versions.len(), 1); assert_eq!(lite.game_versions[0].id, "1.12.2"); assert_eq!(lite.game_versions[0].loaders[0].id, "1.12.2-SNAPSHOT"); let profile = build_liteloader_profile( &lite_metadata, "1.12.2", "1.12.2-SNAPSHOT", ) .unwrap(); assert_eq!( profile.main_class.as_deref(), Some("net.minecraft.launchwrapper.Launch") ); assert!(profile.libraries.iter().any(|library| { library.name == "com.mumfrey:liteloader:1.12.2-SNAPSHOT" })); assert_eq!( profile.java_version.as_ref().map(|java| java.major_version), Some(8) ); assert_eq!( profile .java_version .as_ref() .map(|java| java.component.as_str()), Some("jre-legacy") ); let legacy_fabric_profile: PartialVersionInfo = serde_json::from_value(serde_json::json!({ "id": "fabric-loader-0.19.3-1.8.9", "inheritsFrom": "1.8.9", "releaseTime": "2026-08-19T13:03:54+0000", "time": "2026-08-19T13:03:54+0000", "type": "release", "mainClass": "net.fabricmc.loader.impl.launch.knot.KnotClient", "libraries": [ {"name": "net.legacyfabric:intermediary:1.8.9", "url": "https://maven.legacyfabric.net/"}, {"name": "net.fabricmc:fabric-loader:0.19.3", "url": "https://maven.fabricmc.net/"}, {"name": "org.lwjgl.lwjgl:lwjgl:2.9.4+legacyfabric.17", "url": "https://maven.legacyfabric.net/"} ] })) .unwrap(); assert_eq!( legacy_fabric_profile.main_class.as_deref(), Some("net.fabricmc.loader.impl.launch.knot.KnotClient") ); assert!(legacy_fabric_profile.libraries.iter().any(|library| { library.name == "net.legacyfabric:intermediary:1.8.9" })); assert!(legacy_fabric_profile.arguments.is_none()); assert!(legacy_fabric_profile.minecraft_arguments.is_none()); let cleanroom = cleanroom_manifest(vec![GithubRelease { tag_name: "0.6.11-alpha".to_string(), draft: false, prerelease: false, assets: vec![GithubReleaseAsset { name: "cleanroom-0.6.11-alpha-installer.jar".to_string(), browser_download_url: "https://example.com/cleanroom.jar" .to_string(), }], }]); assert_eq!(cleanroom.game_versions[0].id, "1.12.2"); assert_eq!( cleanroom.game_versions[0].loaders[0].profile_source, LoaderProfileSource::Installer ); } #[test] fn natural_version_order_keeps_latest_first() { let mut versions = ["55.1.9", "55.1.13", "55.1.10"]; versions.sort_by(|left, right| compare_versions(right, left)); assert_eq!(versions, ["55.1.13", "55.1.10", "55.1.9"]); } }