diff --git a/apps/app-frontend/package.json b/apps/app-frontend/package.json index 4f67741f59f..77299e06462 100644 --- a/apps/app-frontend/package.json +++ b/apps/app-frontend/package.json @@ -69,6 +69,5 @@ "vue-component-type-helpers": "^3.1.8", "vue-tsc": "^2.1.6" }, - "packageManager": "pnpm@9.4.0", "web-types": "../../web-types.json" } diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index 564be92c7d3..4fe4b364a6a 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -2241,6 +2241,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload) item.supported_project_types.includes('modpack') || item.name === 'vanilla') + .sort((a, b) => (a.name === 'vanilla' ? -1 : b.name === 'vanilla' ? 1 : 0)) +} + // Gets cached game_versions tags export async function get_game_versions() { return await invoke('plugin:tags|tags_get_game_versions') diff --git a/apps/app-frontend/src/helpers/types.d.ts b/apps/app-frontend/src/helpers/types.d.ts index a17a0acadc2..047bbf91ede 100644 --- a/apps/app-frontend/src/helpers/types.d.ts +++ b/apps/app-frontend/src/helpers/types.d.ts @@ -147,7 +147,7 @@ export type Instance = GameInstance type ReleaseChannel = 'release' | 'beta' | 'alpha' -export type InstanceLoader = 'vanilla' | 'forge' | 'fabric' | 'quilt' | 'neoforge' +export type InstanceLoader = 'vanilla' | 'forge' | 'fabric' | 'quilt' | 'neoforge' | 'ornithe' export type ContentSourceKind = | 'local' diff --git a/apps/app-frontend/src/pages/Browse.vue b/apps/app-frontend/src/pages/Browse.vue index 2fb77e28e9f..a18b900b6ef 100644 --- a/apps/app-frontend/src/pages/Browse.vue +++ b/apps/app-frontend/src/pages/Browse.vue @@ -408,7 +408,7 @@ const instanceFilters = computed(() => { } const platform = instance.value.loader - const supportedModLoaders = ['fabric', 'forge', 'quilt', 'neoforge'] + const supportedModLoaders = ['fabric', 'forge', 'quilt', 'neoforge', 'ornithe'] if (platform && projectType.value === 'mod' && supportedModLoaders.includes(platform)) { filters.push({ type: 'mod_loader', option: platform }) diff --git a/apps/app-frontend/src/pages/instance/components/settings-modal/index.vue b/apps/app-frontend/src/pages/instance/components/settings-modal/index.vue index e5c1de4cfc1..80231663b27 100644 --- a/apps/app-frontend/src/pages/instance/components/settings-modal/index.vue +++ b/apps/app-frontend/src/pages/instance/components/settings-modal/index.vue @@ -9,14 +9,13 @@ import { type TabbedModalTab, useVIntl, } from '@modrinth/ui' -import type { PlatformTag } from '@modrinth/utils' import { useQuery, useQueryClient } from '@tanstack/vue-query' import { computed, nextTick, ref, watch } from 'vue' import { get_project_v3 } from '@/helpers/cache' import { get_linked_modpack_info, getInstanceIconUrl } from '@/helpers/instance' import { get_loader_versions } from '@/helpers/metadata' -import { get_game_versions, get_loaders } from '@/helpers/tags' +import { get_game_versions, get_modpack_loaders } from '@/helpers/tags' import type { GameInstance } from '@/helpers/types' import GeneralSettings from './general-settings.vue' @@ -115,14 +114,6 @@ const tabs = computed(() => [ }, ]) -function getSupportedModpackLoaders() { - return get_loaders().then((value: PlatformTag[]) => - value - .filter((item) => item.supported_project_types.includes('modpack') || item.name === 'vanilla') - .sort((a, b) => (a.name === 'vanilla' ? -1 : b.name === 'vanilla' ? 1 : 0)), - ) -} - // Preload useQuery({ queryKey: ['instance-settings', 'loader-versions', 'fabric'], @@ -136,6 +127,10 @@ useQuery({ queryKey: ['instance-settings', 'loader-versions', 'quilt'], queryFn: () => get_loader_versions('quilt'), }) +useQuery({ + queryKey: ['instance-settings', 'loader-versions', 'ornithe'], + queryFn: () => get_loader_versions('ornithe'), +}) useQuery({ queryKey: ['instance-settings', 'loader-versions', 'neo'], queryFn: () => get_loader_versions('neo'), @@ -146,7 +141,7 @@ useQuery({ }) useQuery({ queryKey: ['instance-settings', 'loaders', 'modpack'], - queryFn: getSupportedModpackLoaders, + queryFn: get_modpack_loaders, }) useQuery({ queryKey: computed(() => ['linkedModpackInfo', props.instance.id]), diff --git a/apps/app-frontend/src/pages/instance/components/settings-modal/installation-settings.vue b/apps/app-frontend/src/pages/instance/components/settings-modal/installation-settings.vue index 6c6d6d0b17e..80b1e33c889 100644 --- a/apps/app-frontend/src/pages/instance/components/settings-modal/installation-settings.vue +++ b/apps/app-frontend/src/pages/instance/components/settings-modal/installation-settings.vue @@ -11,7 +11,7 @@ import { useDebugLogger, useVIntl, } from '@modrinth/ui' -import type { GameVersionTag, PlatformTag } from '@modrinth/utils' +import type { GameVersionTag } from '@modrinth/utils' import { useQuery, useQueryClient } from '@tanstack/vue-query' import { computed, ref } from 'vue' @@ -32,7 +32,7 @@ import { update_repair_modrinth, } from '@/helpers/instance' import { get_loader_versions } from '@/helpers/metadata' -import { get_game_versions, get_loaders } from '@/helpers/tags' +import { get_game_versions, get_modpack_loaders } from '@/helpers/tags' import { injectAppEvents } from '@/providers/app-events' import { provideInstanceBackup } from '@/providers/instance-backup' @@ -60,14 +60,6 @@ debug('metadata load: start', { installStage: instance.value.install_stage, }) -function getSupportedModpackLoaders() { - return get_loaders().then((value: PlatformTag[]) => - value - .filter((item) => item.supported_project_types.includes('modpack') || item.name === 'vanilla') - .sort((a, b) => (a.name === 'vanilla' ? -1 : b.name === 'vanilla' ? 1 : 0)), - ) -} - const fabricVersionsQuery = useQuery({ queryKey: ['instance-settings', 'loader-versions', 'fabric'], queryFn: () => get_loader_versions('fabric') as Promise, @@ -80,6 +72,10 @@ const quiltVersionsQuery = useQuery({ queryKey: ['instance-settings', 'loader-versions', 'quilt'], queryFn: () => get_loader_versions('quilt') as Promise, }) +const ornitheVersionsQuery = useQuery({ + queryKey: ['instance-settings', 'loader-versions', 'ornithe'], + queryFn: () => get_loader_versions('ornithe') as Promise, +}) const neoforgeVersionsQuery = useQuery({ queryKey: ['instance-settings', 'loader-versions', 'neo'], queryFn: () => get_loader_versions('neo') as Promise, @@ -90,7 +86,7 @@ const gameVersionsQuery = useQuery({ }) const loadersQuery = useQuery({ queryKey: ['instance-settings', 'loaders', 'modpack'], - queryFn: getSupportedModpackLoaders, + queryFn: () => get_modpack_loaders() as Promise, }) const metadataLoading = computed(() => @@ -98,6 +94,7 @@ const metadataLoading = computed(() => fabricVersionsQuery, forgeVersionsQuery, quiltVersionsQuery, + ornitheVersionsQuery, neoforgeVersionsQuery, gameVersionsQuery, loadersQuery, @@ -177,6 +174,7 @@ function getManifest(loader: string) { fabric: fabricVersionsQuery.data.value, forge: forgeVersionsQuery.data.value, quilt: quiltVersionsQuery.data.value, + ornithe: ornitheVersionsQuery.data.value, neoforge: neoforgeVersionsQuery.data.value, } const manifest = map[loader] diff --git a/apps/app-frontend/src/providers/content-install.ts b/apps/app-frontend/src/providers/content-install.ts index 469723330c1..d1374fc5668 100644 --- a/apps/app-frontend/src/providers/content-install.ts +++ b/apps/app-frontend/src/providers/content-install.ts @@ -63,8 +63,15 @@ type InstallingProjectDisplay = { organization?: string | null team?: string } -const LOADER_ORDER = ['vanilla', 'fabric', 'quilt', 'neoforge', 'forge'] -const SUPPORTED_LOADERS: Set = new Set(['vanilla', 'forge', 'fabric', 'quilt', 'neoforge']) +const LOADER_ORDER = ['vanilla', 'fabric', 'quilt', 'neoforge', 'forge', 'ornithe'] +const SUPPORTED_LOADERS: Set = new Set([ + 'vanilla', + 'forge', + 'fabric', + 'quilt', + 'neoforge', + 'ornithe', +]) const VANILLA_COMPATIBLE_LOADERS: Set = new Set(['minecraft', 'datapack']) const noCompatibleVersionsMessage = defineMessage({ id: 'app.content-install.no-compatible-versions', diff --git a/apps/daedalus_client/.gitignore b/apps/daedalus_client/.gitignore new file mode 100644 index 00000000000..35a8f08bd5d --- /dev/null +++ b/apps/daedalus_client/.gitignore @@ -0,0 +1 @@ +meta-out/ diff --git a/apps/daedalus_client/src/fabric.rs b/apps/daedalus_client/src/fabric.rs index e8a2f9bdd41..9665b9411d6 100644 --- a/apps/daedalus_client/src/fabric.rs +++ b/apps/daedalus_client/src/fabric.rs @@ -21,9 +21,12 @@ use crate::util::{ use crate::{ Error, FetchResult, MirrorArtifact, UploadFile, insert_mirrored_artifact, }; +use daedalus::get_path_from_artifact; +use daedalus::minecraft::{Argument, JavaVersion, Library, LibraryDownload}; use daedalus::modded::{DUMMY_REPLACE_STRING, Manifest, PartialVersionInfo}; use dashmap::DashMap; use serde::Deserialize; +use std::collections::HashSet; use std::sync::Arc; use tokio::sync::Semaphore; @@ -103,11 +106,14 @@ async fn fetch( .flat_map(|group| { loaders.iter().map(move |loader| ProfileRequest { group: group.id.to_string(), - loader_profile_template_game_version: group - .loader_profile_template_game_version - .clone(), + loader_profile_template_game_version: Some( + group.loader_profile_template_game_version.clone(), + ), game_versions: group.game_versions.clone(), loader_version: loader.version.clone(), + extra_libraries: Vec::new(), + removed_libraries: Vec::new(), + java_version: None, url: format!( "{}/versions/loader/{}/{}/profile/json", meta_url, @@ -123,6 +129,7 @@ async fn fetch( format_version, maven_url, profile_requests, + false, &upload_files, &mirror_artifacts, &semaphore, @@ -271,11 +278,16 @@ async fn fetch( .iter() .map(|loader| ProfileRequest { group: universal_group.id.to_string(), - loader_profile_template_game_version: universal_group - .loader_profile_template_game_version - .clone(), + loader_profile_template_game_version: Some( + universal_group + .loader_profile_template_game_version + .clone(), + ), game_versions: universal_group.game_versions.clone(), loader_version: loader.version.clone(), + extra_libraries: Vec::new(), + removed_libraries: Vec::new(), + java_version: None, url: format!( "{}/versions/loader/{}/{}/profile/json", meta_url, @@ -290,6 +302,7 @@ async fn fetch( format_version, maven_url, profile_requests, + false, &upload_files, &mirror_artifacts, &semaphore, @@ -359,15 +372,20 @@ async fn fetch( }) } -struct ProfileRequest { - group: String, - loader_profile_template_game_version: String, - game_versions: Vec, - loader_version: String, - url: String, +pub(crate) struct ProfileRequest { + pub group: String, + pub loader_profile_template_game_version: Option, + pub game_versions: Vec, + pub loader_version: String, + pub url: String, + pub extra_libraries: Vec, + /// Artifacts of the game's libraries this group's profiles drop. + pub removed_libraries: Vec, + /// The Java version this group's profiles prefer over the game's. + pub java_version: Option, } -fn metadata_version_path( +pub(crate) fn metadata_version_path( mod_loader: &str, format_version: usize, loader_version: &str, @@ -382,24 +400,53 @@ fn metadata_version_path( } } -async fn fetch_metadata_profiles( +/// Fetches, patches and queues the upload of each requested loader profile. +/// +/// With `allow_failures`, profiles that fail to download or parse are skipped +/// and returned as `(group, loader_version)` pairs instead of failing the +/// whole fetch. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn fetch_metadata_profiles( mod_loader: &str, format_version: usize, maven_url: &str, profile_requests: Vec, + allow_failures: bool, upload_files: &DashMap, mirror_artifacts: &DashMap, semaphore: &Arc, -) -> Result<(), Error> { - let version_manifests = futures::future::try_join_all( +) -> Result, Error> { + let downloads = futures::future::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>>()?; + .await; + + let mut failed = HashSet::new(); + let mut fetched = Vec::with_capacity(profile_requests.len()); + + for (download, request) in downloads.into_iter().zip(profile_requests) { + let version_info = download.and_then(|x| { + Ok(serde_json::from_slice::(&x)?) + }); + + match version_info { + Ok(version_info) => fetched.push((version_info, request)), + Err(err) if allow_failures => { + tracing::warn!( + url = %request.url, + error = %err, + "Skipping {mod_loader} loader profile" + ); + failed.insert((request.group, request.loader_version)); + } + Err(err) => return Err(err), + } + } + + let (version_manifests, profile_requests): (Vec<_>, Vec<_>) = + fetched.into_iter().unzip(); let patched_version_manifests = version_manifests .into_iter() @@ -407,11 +454,18 @@ async fn fetch_metadata_profiles( .map(|(mut version_info, request)| { patch_version_info( &mut version_info, - &request.loader_profile_template_game_version, + request.loader_profile_template_game_version.as_deref(), &request.game_versions, maven_url, + &request.extra_libraries, mirror_artifacts, )?; + if let Some(java_version) = &request.java_version { + version_info.java_version = Some(java_version.clone()); + } + version_info + .removed_libraries + .clone_from(&request.removed_libraries); Ok(version_info) }) @@ -441,18 +495,78 @@ async fn fetch_metadata_profiles( ); }); + Ok(failed) +} + +fn template_game_version(value: &str, game_version: Option<&str>) -> String { + let Some(game_version) = game_version else { + return value.to_string(); + }; + let is_boundary = + |x: Option| x.is_none_or(|x| !x.is_alphanumeric() && x != '.'); + let mut templated = String::with_capacity(value.len()); + let mut copied = 0; + + for (start, _) in value.match_indices(game_version) { + let end = start + game_version.len(); + + if is_boundary(value[..start].chars().next_back()) + && is_boundary(value[end..].chars().next()) + { + templated.push_str(&value[copied..start]); + templated.push_str(DUMMY_REPLACE_STRING); + copied = end; + } + } + + templated.push_str(&value[copied..]); + templated +} + +/// Swaps the version of a library for the placeholder where it is exactly +/// the game version, so `1.14` is left alone in `1.14-pre1`. +fn template_library_name(name: &str, game_version: Option<&str>) -> String { + name.split(':') + .map(|x| { + if Some(x) == game_version { + DUMMY_REPLACE_STRING + } else { + x + } + }) + .collect::>() + .join(":") +} + +/// Mirrors a library download and points it at the mirrored copy. +fn mirror_library_download( + artifact: &str, + download: &mut LibraryDownload, + mirror_artifacts: &DashMap, +) -> Result<(), Error> { + insert_mirrored_artifact( + artifact, + Some(download.sha1.clone()), + vec![download.url.clone()], + true, + mirror_artifacts, + )?; + download.url = + format_url(&format!("maven/{}", get_path_from_artifact(artifact)?)); + Ok(()) } fn patch_version_info( version_info: &mut PartialVersionInfo, - game_version: &str, + game_version: Option<&str>, game_versions: &[String], maven_url: &str, + extra_libraries: &[Library], mirror_artifacts: &DashMap, ) -> Result<(), Error> { for lib in &mut version_info.libraries { - let new_name = lib.name.replace(game_version, DUMMY_REPLACE_STRING); + let new_name = template_library_name(&lib.name, game_version); // 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" { @@ -471,8 +585,8 @@ fn patch_version_info( )?; } else { for concrete_game_version in game_versions { - let concrete_name = - lib.name.replace(game_version, concrete_game_version); + let concrete_name = new_name + .replace(DUMMY_REPLACE_STRING, concrete_game_version); insert_mirrored_artifact( &concrete_name, @@ -489,11 +603,51 @@ fn patch_version_info( 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); + for lib in extra_libraries { + let mut lib = lib.clone(); + + if let Some(downloads) = lib.downloads.as_mut() { + if let Some(artifact) = downloads.artifact.as_mut() { + mirror_library_download(&lib.name, artifact, mirror_artifacts)?; + } + + for (classifier, download) in + downloads.classifiers.iter_mut().flatten() + { + mirror_library_download( + &format!("{}:{classifier}", lib.name), + download, + mirror_artifacts, + )?; + } + } else { + insert_mirrored_artifact( + &lib.name, + None, + vec![lib.url.unwrap_or_else(|| maven_url.to_string())], + false, + mirror_artifacts, + )?; + lib.url = Some(format_url("maven/")); + } + + version_info.libraries.push(lib); + } + + for argument in version_info + .arguments + .iter_mut() + .flat_map(|x| x.values_mut()) + .flatten() + { + if let Argument::Normal(value) = argument { + *value = template_game_version(value, game_version); + } + } + + version_info.id = template_game_version(&version_info.id, game_version); + version_info.inherits_from = + template_game_version(&version_info.inherits_from, game_version); Ok(()) } @@ -507,7 +661,7 @@ struct FabricVersions { } #[derive(Deserialize, Debug, Clone)] -struct FabricLoaderVersion { +pub(crate) struct FabricLoaderVersion { // pub separator: String, // pub build: u32, // pub maven: String, @@ -523,7 +677,39 @@ struct FabricIntermediaryVersion { } #[derive(Deserialize, Debug, Clone)] -struct FabricGameVersion { +pub(crate) struct FabricGameVersion { pub version: String, pub stable: bool, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn game_versions_inside_longer_versions_are_not_templated() { + let template = |value| template_game_version(value, Some("1.8")); + + assert_eq!( + template("fabric-loader-0.11.8-1.8-ornithe-gen2"), + format!("fabric-loader-0.11.8-{DUMMY_REPLACE_STRING}-ornithe-gen2") + ); + assert_eq!(template("a:b:1.8.9"), "a:b:1.8.9"); + assert_eq!(template_game_version("a:b:1.8", None), "a:b:1.8"); + } + + #[test] + fn only_whole_library_versions_are_templated() { + let template = |name| template_library_name(name, Some("1.8")); + + assert_eq!( + template("net.ornithemc:calamus-intermediary-gen2:1.8"), + format!( + "net.ornithemc:calamus-intermediary-gen2:{DUMMY_REPLACE_STRING}" + ) + ); + assert_eq!(template("a:b:1.8-pre1"), "a:b:1.8-pre1"); + assert_eq!(template("a:b:0.11.8"), "a:b:0.11.8"); + assert_eq!(template_library_name("a:b:1.8", None), "a:b:1.8"); + } +} diff --git a/apps/daedalus_client/src/forge.rs b/apps/daedalus_client/src/forge.rs index 2ff51b99ee7..76508f0c980 100644 --- a/apps/daedalus_client/src/forge.rs +++ b/apps/daedalus_client/src/forge.rs @@ -444,6 +444,9 @@ async fn fetch( .cloned() .collect() }), + java_version: None, + logging: None, + removed_libraries: Vec::new(), libraries: install_profile .version_info .libraries diff --git a/apps/daedalus_client/src/main.rs b/apps/daedalus_client/src/main.rs index d2a07cf3e6b..7ce9b9eab9b 100644 --- a/apps/daedalus_client/src/main.rs +++ b/apps/daedalus_client/src/main.rs @@ -18,6 +18,7 @@ mod fabric; mod forge; mod metadata_groups; mod minecraft; +mod ornithe; pub mod util; pub use error::{Error, ErrorKind, Result}; @@ -72,6 +73,13 @@ async fn main() -> Result<()> { } } + if should_fetch(only_loader.as_deref(), "ornithe") { + match ornithe::fetch(semaphore.clone()).await { + Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched), + Err(err) => tracing::warn!(error = %err, "Ornithe fetch failed"), + } + } + if should_fetch(only_loader.as_deref(), "neo") { match forge::fetch_neo(semaphore.clone()).await { Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched), diff --git a/apps/daedalus_client/src/ornithe.rs b/apps/daedalus_client/src/ornithe.rs new file mode 100644 index 00000000000..9ed6b2cbbe7 --- /dev/null +++ b/apps/daedalus_client/src/ornithe.rs @@ -0,0 +1,569 @@ +//! Fetches Ornithe loader metadata. + +use crate::fabric::{ + FabricGameVersion, FabricLoaderVersion, ProfileRequest, + fetch_metadata_profiles, metadata_version_path, +}; +use crate::util::{ + fetch_json, fetch_optional_json, format_url, retain_manifest_versions, +}; +use crate::{Error, FetchResult, MirrorArtifact, UploadFile}; +use daedalus::minecraft::{ + JavaVersion, Library, VERSION_MANIFEST_URL, VersionManifest, +}; +use daedalus::modded::{ + CURRENT_ORNITHE_FORMAT_VERSION, LoaderVersion, Manifest, Version, + VersionGroup, +}; +use dashmap::DashMap; +use itertools::Itertools; +use serde::Deserialize; +use std::collections::HashSet; +use std::sync::Arc; +use tokio::sync::Semaphore; + +const MOD_LOADER: &str = "ornithe"; +const META_URL: &str = "https://meta.ornithemc.net/v3/versions/gen2"; +const MAVEN_URL: &str = "https://maven.fabricmc.net/"; +const MC_VERSIONS_URL: &str = + "https://ornithemc.net/mc-versions/gen2/version/manifest"; +const LWJGL_MAVEN_HOST: &str = "maven.legacyfabric.net"; + +/// Mojang game version IDs paired with the Ornithe ID of the same client JAR. +const GAME_VERSION_ALIASES: &[(&str, &str)] = &[ + ("1.0", "1.0.0"), + ("b1.3b", "b1.3"), + ("a1.2.2b", "a1.2.2"), + ("a1.2.2a", "a1.2.2-1624"), + ("c0.30_01c", "c0.30-c-renew"), +]; + +fn ornithe_game_version(mojang_game_version: &str) -> String { + GAME_VERSION_ALIASES + .iter() + .find(|(mojang, _)| *mojang == mojang_game_version) + .map_or_else( + || mojang_game_version.replace(" Pre-Release ", "-pre"), + |(_, ornithe)| ornithe.to_string(), + ) +} + +struct GameVersion { + id: String, + ornithe_id: String, + stable: bool, + /// The URL of Mojang's version JSON. + url: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct GameVersionInfo { + libraries: Vec, + java_version: Option, +} + +struct GameVersionUpgrades<'a> { + game_version: &'a GameVersion, + libraries: Vec, + removed_libraries: Vec, + java_version: Option, +} + +fn library_artifact(library: &Library) -> &str { + library + .name + .rsplit_once(':') + .map_or(library.name.as_str(), |x| x.0) +} + +/// The artifacts of Mojang's libraries that Ornithe's version JSON drops. +fn removed_libraries( + mojang_libraries: &[Library], + ornithe_libraries: &[Library], +) -> Vec { + let ornithe_artifacts = ornithe_libraries + .iter() + .map(library_artifact) + .collect::>(); + + mojang_libraries + .iter() + .map(library_artifact) + .filter(|x| !ornithe_artifacts.contains(x)) + .sorted() + .dedup() + .map(str::to_string) + .collect() +} + +fn is_lwjgl_upgrade(library: &Library) -> bool { + let downloads = library.downloads.as_ref(); + + downloads + .and_then(|x| x.artifact.as_ref()) + .into_iter() + .chain( + downloads + .and_then(|x| x.classifiers.as_ref()) + .into_iter() + .flat_map(|x| x.values()), + ) + .map(|x| x.url.as_str()) + .chain(library.url.as_deref()) + .any(|url| url.contains(LWJGL_MAVEN_HOST)) +} + +struct LibraryGroup { + id: String, + libraries: Vec, + removed_libraries: Vec, + /// The Java version Ornithe asks for, in place of the game's. + java_version: Option, + game_versions: Vec, + /// Set for the group of a game version Ornithe names differently. + ornithe_game_version: Option, +} + +impl LibraryGroup { + fn profile_game_version(&self) -> &str { + self.ornithe_game_version.as_deref().unwrap_or_else(|| { + self.game_versions + .iter() + .max_by_key(|x| x.len()) + .expect("library group should have a game version") + }) + } +} + +fn group_by_libraries<'a>( + game_versions: impl IntoIterator>, +) -> Vec { + let mut groups = Vec::::new(); + + for GameVersionUpgrades { + game_version, + mut libraries, + removed_libraries, + java_version, + } in game_versions + { + libraries.sort_by(|a, b| a.name.cmp(&b.name)); + + let ornithe_game_version = (game_version.id != game_version.ornithe_id) + .then(|| game_version.ornithe_id.clone()); + let java = java_version + .as_ref() + .map(|x| format!("java:{}:{}", x.component, x.major_version)); + let removed = removed_libraries + .iter() + .map(|x| format!("removed:{x}")) + .collect::>(); + let id = sha1_smol::Sha1::from( + libraries + .iter() + .map(|x| x.name.as_str()) + .chain(ornithe_game_version.as_deref()) + .chain(java.as_deref()) + .chain(removed.iter().map(String::as_str)) + .join("\n"), + ) + .hexdigest()[..8] + .to_string(); + + if let Some(group) = groups.iter_mut().find(|x| x.id == id) { + group.game_versions.push(game_version.id.clone()); + } else { + groups.push(LibraryGroup { + id, + libraries, + removed_libraries, + java_version, + game_versions: vec![game_version.id.clone()], + ornithe_game_version, + }); + } + } + + groups +} + +#[tracing::instrument(skip(semaphore))] +pub async fn fetch(semaphore: Arc) -> Result { + let format_version = CURRENT_ORNITHE_FORMAT_VERSION; + let upload_files = DashMap::new(); + let mirror_artifacts = DashMap::::new(); + let manifest_path = format!("{MOD_LOADER}/v{format_version}/manifest.json"); + + let modrinth_manifest = fetch_optional_json::( + &format_url(&manifest_path), + &semaphore, + ) + .await?; + let minecraft_manifest = + fetch_json::(VERSION_MANIFEST_URL, &semaphore).await?; + let loaders = fetch_json::>( + &format!("{META_URL}/fabric-loader"), + &semaphore, + ) + .await?; + let game_versions = fetch_json::>( + &format!("{META_URL}/game"), + &semaphore, + ) + .await? + .into_iter() + .filter_map(|x| { + let mojang_version = minecraft_manifest + .versions + .iter() + .find(|y| ornithe_game_version(&y.id) == x.version)?; + + Some(GameVersion { + id: mojang_version.id.clone(), + ornithe_id: x.version, + stable: x.stable, + url: mojang_version.url.clone(), + }) + }) + .collect::>(); + + let library_upgrades = + futures::future::join_all(game_versions.iter().map(|x| { + let semaphore = &semaphore; + async move { + let upgrades = async { + let mut libraries = fetch_json::>( + &format!("{META_URL}/libraries/{}", x.ornithe_id), + semaphore, + ) + .await?; + let version_info = fetch_json::( + &format!("{MC_VERSIONS_URL}/{}.json", x.ornithe_id), + semaphore, + ) + .await?; + let mojang_version_info = + fetch_json::(&x.url, semaphore) + .await?; + let removed_libraries = removed_libraries( + &mojang_version_info.libraries, + &version_info.libraries, + ); + + libraries.extend( + version_info + .libraries + .into_iter() + .filter(is_lwjgl_upgrade), + ); + + Ok::<_, Error>(GameVersionUpgrades { + game_version: x, + libraries, + removed_libraries, + java_version: version_info.java_version, + }) + } + .await; + + match upgrades { + Ok(upgrades) => Some(upgrades), + Err(err) => { + tracing::warn!( + game_version = %x.ornithe_id, + error = %err, + "Skipping Ornithe game version" + ); + None + } + } + } + })) + .await; + let groups = group_by_libraries(library_upgrades.into_iter().flatten()); + + let published_profiles = modrinth_manifest + .iter() + .flat_map(|x| &x.version_groups) + .flat_map(|group| { + group + .loaders + .iter() + .map(|loader| (group.id.as_str(), loader.id.as_str())) + }) + .collect::>(); + let published_game_versions = modrinth_manifest + .iter() + .flat_map(|x| &x.game_versions) + .filter_map(|x| Some((x.version_group.as_deref()?, x.id.as_str()))) + .collect::>(); + + let published_profiles = &published_profiles; + let published_game_versions = &published_game_versions; + let profile_requests = groups + .iter() + .flat_map(|group| { + let group_unchanged = group.game_versions.iter().all(|x| { + published_game_versions + .contains(&(group.id.as_str(), x.as_str())) + }); + + loaders + .iter() + .filter(move |loader| { + !group_unchanged + || !published_profiles.contains(&( + group.id.as_str(), + loader.version.as_str(), + )) + }) + .map(move |loader| ProfileRequest { + group: group.id.clone(), + loader_profile_template_game_version: group + .ornithe_game_version + .is_none() + .then(|| group.profile_game_version().to_string()), + game_versions: group.game_versions.clone(), + loader_version: loader.version.clone(), + url: format!( + "{META_URL}/fabric-loader/{}/{}/profile/json", + group.profile_game_version(), + loader.version + ), + extra_libraries: group.libraries.clone(), + removed_libraries: group.removed_libraries.clone(), + java_version: group.java_version.clone(), + }) + }) + .collect::>(); + + let failed_profiles = fetch_metadata_profiles( + MOD_LOADER, + format_version, + MAVEN_URL, + profile_requests, + true, + &upload_files, + &mirror_artifacts, + &semaphore, + ) + .await?; + + let mut manifest = Manifest { + game_versions: game_versions + .into_iter() + .filter_map(|game_version| { + let group = groups + .iter() + .find(|x| x.game_versions.contains(&game_version.id))?; + + Some(Version { + version_group: Some(group.id.clone()), + id: game_version.id, + stable: game_version.stable, + loaders: Vec::new(), + }) + }) + .collect(), + version_groups: groups + .iter() + .map(|group| VersionGroup { + id: group.id.clone(), + loaders: loaders + .iter() + .filter(|loader| { + !failed_profiles.contains(&( + group.id.clone(), + loader.version.clone(), + )) + }) + .map(|loader| LoaderVersion { + id: loader.version.clone(), + url: format_url(&metadata_version_path( + MOD_LOADER, + format_version, + &loader.version, + &group.id, + )), + stable: loader.stable, + }) + .collect(), + }) + .collect(), + }; + + retain_manifest_versions(&mut manifest, modrinth_manifest.as_ref()); + + let used_groups = manifest + .game_versions + .iter() + .filter_map(|x| x.version_group.clone()) + .collect::>(); + manifest + .version_groups + .retain(|x| used_groups.contains(&x.id)); + + upload_files.insert( + manifest_path, + UploadFile { + file: bytes::Bytes::from(serde_json::to_vec(&manifest)?), + content_type: Some("application/json".to_string()), + }, + ); + + Ok(FetchResult { + upload_files, + mirror_artifacts, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn libraries(names: &[&str]) -> Vec { + names + .iter() + .map(|name| { + serde_json::from_value(serde_json::json!({ "name": name })) + .unwrap() + }) + .collect() + } + + fn upgrades<'a>( + game_version: &'a GameVersion, + library_names: &[&str], + java_major_version: Option, + ) -> GameVersionUpgrades<'a> { + GameVersionUpgrades { + game_version, + libraries: libraries(library_names), + removed_libraries: Vec::new(), + java_version: java_major_version.map(|major_version| JavaVersion { + component: "jre-legacy".to_string(), + major_version, + }), + } + } + + fn game_version(id: &str) -> GameVersion { + GameVersion { + id: id.to_string(), + ornithe_id: ornithe_game_version(id), + stable: true, + url: String::new(), + } + } + + #[test] + fn game_versions_with_the_same_upgrades_share_a_group() { + let game_versions = + ["1.8.9", "13w16a-04192037", "1.14.4"].map(game_version); + let groups = group_by_libraries([ + upgrades(&game_versions[0], &["a:a:1", "b:b:1"], Some(8)), + upgrades(&game_versions[1], &["b:b:1", "a:a:1"], Some(8)), + upgrades(&game_versions[2], &["a:a:1"], Some(8)), + ]); + + assert_eq!(groups.len(), 2); + assert_eq!(groups[0].game_versions, ["1.8.9", "13w16a-04192037"]); + assert_eq!(groups[0].profile_game_version(), "13w16a-04192037"); + assert_eq!(groups[1].game_versions, ["1.14.4"]); + assert_ne!(groups[0].id, groups[1].id); + } + + #[test] + fn renamed_game_versions_get_an_untemplated_group() { + let game_versions = + ["1.0.1", "1.0", "1.14.2 Pre-Release 4"].map(game_version); + let groups = group_by_libraries( + game_versions + .iter() + .map(|x| upgrades(x, &["a:a:1"], Some(8))), + ); + + assert_eq!(groups.len(), 3); + assert_eq!(groups[0].ornithe_game_version, None); + assert_eq!(groups[1].game_versions, ["1.0"]); + assert_eq!(groups[1].profile_game_version(), "1.0.0"); + assert_eq!(groups[2].profile_game_version(), "1.14.2-pre4"); + } + + #[test] + fn game_versions_asking_for_different_java_get_separate_groups() { + let game_versions = ["1.8.9", "1.12.2"].map(game_version); + let groups = group_by_libraries([ + upgrades(&game_versions[0], &["a:a:1"], Some(8)), + upgrades(&game_versions[1], &["a:a:1"], Some(25)), + ]); + + assert_eq!(groups.len(), 2); + assert_eq!(groups[1].java_version.as_ref().unwrap().major_version, 25); + assert_ne!(groups[0].id, groups[1].id); + } + + #[test] + fn libraries_ornithe_drops_are_removed() { + let removed = removed_libraries( + &libraries(&[ + "net.minecraft:launchwrapper:1.5", + "org.ow2.asm:asm-all:4.1", + "org.lwjgl.lwjgl:lwjgl:2.9.0", + "org.lwjgl.lwjgl:lwjgl:2.9.1-nightly-20130708-debug3", + ]), + &libraries(&[ + "org.ow2.asm:asm:7.0", + "net.minecraft:launchwrapper:1.5", + "org.lwjgl.lwjgl:lwjgl:2.9.4+legacyfabric.15", + ]), + ); + + assert_eq!(removed, ["org.ow2.asm:asm-all"]); + } + + #[test] + fn game_versions_dropping_different_libraries_get_separate_groups() { + let game_versions = ["1.5.2", "1.6.4"].map(game_version); + let mut dropping_asm_all = + upgrades(&game_versions[0], &["a:a:1"], Some(8)); + dropping_asm_all.removed_libraries = + vec!["org.ow2.asm:asm-all".to_string()]; + let groups = group_by_libraries([ + dropping_asm_all, + upgrades(&game_versions[1], &["a:a:1"], Some(8)), + ]); + + assert_eq!(groups.len(), 2); + assert_eq!(groups[0].removed_libraries, ["org.ow2.asm:asm-all"]); + assert!(groups[1].removed_libraries.is_empty()); + } + + #[test] + fn only_legacy_fabric_libraries_are_lwjgl_upgrades() { + let libraries: Vec = serde_json::from_value(serde_json::json!([ + { + "name": "org.lwjgl.lwjgl:lwjgl-platform:2.9.4+legacyfabric.15", + "downloads": { "classifiers": { "natives-osx": { + "sha1": "a", + "size": 1, + "url": "https://maven.legacyfabric.net/natives-osx.jar" + } } }, + "natives": { "osx-arm64": "natives-osx" } + }, + { + "name": "net.java.jinput:jinput:2.0.5", + "downloads": { "artifact": { + "sha1": "b", + "size": 2, + "url": "https://libraries.minecraft.net/jinput.jar" + } } + } + ])) + .unwrap(); + + assert!(is_lwjgl_upgrade(&libraries[0])); + assert!(!is_lwjgl_upgrade(&libraries[1])); + } +} diff --git a/apps/daedalus_client/src/util.rs b/apps/daedalus_client/src/util.rs index 63776babdd5..179ef94527a 100644 --- a/apps/daedalus_client/src/util.rs +++ b/apps/daedalus_client/src/util.rs @@ -396,7 +396,11 @@ async fn download_file_inner( const RETRIES: u32 = 10; for attempt in 1..=(RETRIES + 1) { let result = REQWEST_CLIENT - .get(url.replace("http://", "https://")) + .get(if url.starts_with("http://localhost") { + url.to_string() + } else { + url.replace("http://", "https://") + }) .send() .await .and_then(|x| x.error_for_status()); diff --git a/apps/frontend/src/helpers/infer/loader-parsers.ts b/apps/frontend/src/helpers/infer/loader-parsers.ts index 1cbbc527a10..3cf55693530 100644 --- a/apps/frontend/src/helpers/infer/loader-parsers.ts +++ b/apps/frontend/src/helpers/infer/loader-parsers.ts @@ -367,6 +367,9 @@ export function createLoaderParsers( if ('quilt-loader' in metadata.dependencies) { loaders.push('quilt') } + if ('ornithe-loader' in metadata.dependencies) { + loaders.push('ornithe') + } return { name: `${project.title} ${metadata.versionId}`, diff --git a/apps/frontend/src/providers/manage-server-compatibility-modal.ts b/apps/frontend/src/providers/manage-server-compatibility-modal.ts index 017b0fe543b..8618b020333 100644 --- a/apps/frontend/src/providers/manage-server-compatibility-modal.ts +++ b/apps/frontend/src/providers/manage-server-compatibility-modal.ts @@ -98,6 +98,7 @@ export function createServerCompatibilityContext( if ('neoforge' in metadata.dependencies) loaders.push('neoforge') if ('fabric-loader' in metadata.dependencies) loaders.push('fabric') if ('quilt-loader' in metadata.dependencies) loaders.push('quilt') + if ('ornithe-loader' in metadata.dependencies) loaders.push('ornithe') if (metadata.dependencies.minecraft) { gameVersions = [metadata.dependencies.minecraft] } diff --git a/apps/labrinth/migrations/20261005120000_ornithe_mrpack_loader.sql b/apps/labrinth/migrations/20261005120000_ornithe_mrpack_loader.sql new file mode 100644 index 00000000000..e3e1613fdd8 --- /dev/null +++ b/apps/labrinth/migrations/20261005120000_ornithe_mrpack_loader.sql @@ -0,0 +1,3 @@ +INSERT INTO loader_field_enum_values (enum_id, value) +SELECT id, 'ornithe' FROM loader_field_enums WHERE enum_name = 'mrpack_loaders' +ON CONFLICT (enum_id, value) DO NOTHING; diff --git a/apps/labrinth/src/models/v3/pack.rs b/apps/labrinth/src/models/v3/pack.rs index 4993e86df72..e07387c3511 100644 --- a/apps/labrinth/src/models/v3/pack.rs +++ b/apps/labrinth/src/models/v3/pack.rs @@ -88,6 +88,7 @@ pub enum PackDependency { Neoforge, FabricLoader, QuiltLoader, + OrnitheLoader, Minecraft, } @@ -106,6 +107,7 @@ impl PackDependency { PackDependency::FabricLoader => "fabric-loader", PackDependency::Minecraft => "minecraft", PackDependency::QuiltLoader => "quilt-loader", + PackDependency::OrnitheLoader => "ornithe-loader", } } } diff --git a/apps/labrinth/src/routes/v2/tags.rs b/apps/labrinth/src/routes/v2/tags.rs index 97926f904f4..27b515f3eaf 100644 --- a/apps/labrinth/src/routes/v2/tags.rs +++ b/apps/labrinth/src/routes/v2/tags.rs @@ -123,7 +123,7 @@ pub async fn loader_list( // a project type before any versions are set. supported_project_types.push("project".to_string()); - if ["forge", "fabric", "quilt", "neoforge"] + if ["forge", "fabric", "quilt", "neoforge", "ornithe"] .contains(&&*l.name) { supported_project_types.push("modpack".to_string()); diff --git a/packages/app-lib/java/src/main/java/com/modrinth/theseus/agent/transformers/MinecraftTransformer.java b/packages/app-lib/java/src/main/java/com/modrinth/theseus/agent/transformers/MinecraftTransformer.java index a044a2ee911..a8a3b7f3521 100644 --- a/packages/app-lib/java/src/main/java/com/modrinth/theseus/agent/transformers/MinecraftTransformer.java +++ b/packages/app-lib/java/src/main/java/com/modrinth/theseus/agent/transformers/MinecraftTransformer.java @@ -7,6 +7,7 @@ import org.objectweb.asm.tree.AbstractInsnNode; import org.objectweb.asm.tree.ClassNode; import org.objectweb.asm.tree.FrameNode; +import org.objectweb.asm.tree.InsnList; import org.objectweb.asm.tree.InsnNode; import org.objectweb.asm.tree.JumpInsnNode; import org.objectweb.asm.tree.LabelNode; @@ -17,6 +18,8 @@ public final class MinecraftTransformer extends ClassNodeTransformer { private static final String SET_SERVER_NAME_DESC = "(Ljava/lang/String;I)V"; + private static final String QUICK_PLAY_NAME = "modrinth$quickPlay"; + private static final String QUICK_PLAY_DESC = "(Lnet/minecraft/client/Minecraft;)V"; private static final InsnPattern[] INITIALIZE_THIS_PATTERN = {InsnPattern.opcode(Opcodes.INVOKESPECIAL)}; @Override @@ -46,7 +49,7 @@ private static boolean addServerJoinSupport(ClassNode classNode) { } } } - if (constructor == null) { + if (constructor == null || setServerName == null) { return false; } @@ -55,44 +58,36 @@ private static boolean addServerJoinSupport(ClassNode classNode) { return true; } - final LabelNode noQuickPlayLabel = new LabelNode(); - final LabelNode doneQuickPlayLabel = new LabelNode(); - it.add(new LdcInsnNode("modrinth.internal.quickPlay.host")); - // String - it.add(new MethodInsnNode( - Opcodes.INVOKESTATIC, "java/lang/System", "getProperty", "(Ljava/lang/String;)Ljava/lang/String;")); - // String - it.add(new InsnNode(Opcodes.DUP)); - // String String - it.add(new JumpInsnNode(Opcodes.IFNULL, noQuickPlayLabel)); - // String it.add(new VarInsnNode(Opcodes.ALOAD, 0)); - // String Minecraft - it.add(new InsnNode(Opcodes.SWAP)); - // Minecraft String - it.add(new LdcInsnNode("modrinth.internal.quickPlay.port")); - // Minecraft String String - it.add(new MethodInsnNode( + it.add(new MethodInsnNode(Opcodes.INVOKESTATIC, classNode.name, QUICK_PLAY_NAME, QUICK_PLAY_DESC)); + + final MethodNode quickPlay = new MethodNode( + Opcodes.ACC_PRIVATE | Opcodes.ACC_STATIC | Opcodes.ACC_SYNTHETIC, + QUICK_PLAY_NAME, + QUICK_PLAY_DESC, + null, + null); + final InsnList insns = quickPlay.instructions; + final LabelNode doneLabel = new LabelNode(); + insns.add(new LdcInsnNode("modrinth.internal.quickPlay.host")); + insns.add(new MethodInsnNode( Opcodes.INVOKESTATIC, "java/lang/System", "getProperty", "(Ljava/lang/String;)Ljava/lang/String;")); - // Minecraft String String - it.add(new MethodInsnNode(Opcodes.INVOKESTATIC, "java/lang/Integer", "parseInt", "(Ljava/lang/String;)I")); - // Minecraft String int - it.add(new MethodInsnNode( - Opcodes.INVOKEVIRTUAL, "net/minecraft/client/Minecraft", setServerName, SET_SERVER_NAME_DESC)); - // - it.add(new JumpInsnNode(Opcodes.GOTO, doneQuickPlayLabel)); - it.add(noQuickPlayLabel); - if (needsStackMap(classNode)) { - it.add(new FrameNode(Opcodes.F_SAME, 0, null, 0, null)); - } - // String - it.add(new InsnNode(Opcodes.POP)); - // - it.add(doneQuickPlayLabel); + insns.add(new VarInsnNode(Opcodes.ASTORE, 1)); + insns.add(new VarInsnNode(Opcodes.ALOAD, 1)); + insns.add(new JumpInsnNode(Opcodes.IFNULL, doneLabel)); + insns.add(new VarInsnNode(Opcodes.ALOAD, 0)); + insns.add(new VarInsnNode(Opcodes.ALOAD, 1)); + insns.add(new LdcInsnNode("modrinth.internal.quickPlay.port")); + insns.add(new MethodInsnNode( + Opcodes.INVOKESTATIC, "java/lang/System", "getProperty", "(Ljava/lang/String;)Ljava/lang/String;")); + insns.add(new MethodInsnNode(Opcodes.INVOKESTATIC, "java/lang/Integer", "parseInt", "(Ljava/lang/String;)I")); + insns.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, classNode.name, setServerName, SET_SERVER_NAME_DESC)); + insns.add(doneLabel); if (needsStackMap(classNode)) { - it.add(new FrameNode(Opcodes.F_SAME, 0, null, 0, null)); + insns.add(new FrameNode(Opcodes.F_APPEND, 1, new Object[] {"java/lang/String"}, 0, null)); } - // + insns.add(new InsnNode(Opcodes.RETURN)); + classNode.methods.add(quickPlay); return true; } diff --git a/packages/app-lib/java/src/test/java/com/modrinth/theseus/agent/transformers/MinecraftTransformerTest.java b/packages/app-lib/java/src/test/java/com/modrinth/theseus/agent/transformers/MinecraftTransformerTest.java new file mode 100644 index 00000000000..ee9bc4955a5 --- /dev/null +++ b/packages/app-lib/java/src/test/java/com/modrinth/theseus/agent/transformers/MinecraftTransformerTest.java @@ -0,0 +1,60 @@ +package com.modrinth.theseus.agent.transformers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; + +class MinecraftTransformerTest { + private static final String MINECRAFT = "net/minecraft/client/Minecraft"; + + @ParameterizedTest + @ValueSource(ints = {Opcodes.V1_5, Opcodes.V1_8}) + void injectedQuickPlayVerifiesAndRuns(int classVersion) throws Exception { + System.setProperty("modrinth.internal.quickPlay.serverVersion", "INJECTED"); + System.setProperty("modrinth.internal.quickPlay.host", "example.com"); + System.setProperty("modrinth.internal.quickPlay.port", "25566"); + + final ClassReader reader = new ClassReader(fakeMinecraft(classVersion)); + final ClassWriter writer = new ClassWriter(reader, ClassWriter.COMPUTE_MAXS); + assertTrue(new MinecraftTransformer().transform(reader, writer)); + final byte[] transformed = writer.toByteArray(); + + final Class minecraft = new ClassLoader() { + Class define() { + return defineClass(MINECRAFT.replace('/', '.'), transformed, 0, transformed.length); + } + }.define(); + minecraft.getDeclaredConstructor().newInstance(); + assertEquals("example.com", minecraft.getField("host").get(null)); + assertEquals(25566, minecraft.getField("port").get(null)); + } + + private static byte[] fakeMinecraft(int classVersion) { + final ClassWriter writer = new ClassWriter(0); + writer.visit(classVersion, Opcodes.ACC_PUBLIC, MINECRAFT, null, "java/lang/Object", null); + writer.visitField(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "host", "Ljava/lang/String;", null, null); + writer.visitField(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "port", "I", null, null); + + MethodVisitor method = writer.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null); + method.visitVarInsn(Opcodes.ALOAD, 0); + method.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "", "()V", false); + method.visitInsn(Opcodes.RETURN); + method.visitMaxs(1, 1); + + method = writer.visitMethod(Opcodes.ACC_PUBLIC, "setServer", "(Ljava/lang/String;I)V", null, null); + method.visitVarInsn(Opcodes.ALOAD, 1); + method.visitFieldInsn(Opcodes.PUTSTATIC, MINECRAFT, "host", "Ljava/lang/String;"); + method.visitVarInsn(Opcodes.ILOAD, 2); + method.visitFieldInsn(Opcodes.PUTSTATIC, MINECRAFT, "port", "I"); + method.visitInsn(Opcodes.RETURN); + method.visitMaxs(1, 3); + + return writer.toByteArray(); + } +} diff --git a/packages/app-lib/src/api/instance/export_mrpack.rs b/packages/app-lib/src/api/instance/export_mrpack.rs index d8c7d14f3b6..016c79dec25 100644 --- a/packages/app-lib/src/api/instance/export_mrpack.rs +++ b/packages/app-lib/src/api/instance/export_mrpack.rs @@ -634,6 +634,9 @@ pub async fn create_mrpack_json( (ModLoader::Quilt, Some(v)) => { dependencies.insert(PackDependency::QuiltLoader, v) } + (ModLoader::Ornithe, Some(v)) => { + dependencies.insert(PackDependency::OrnitheLoader, v) + } (ModLoader::Vanilla, _) => None, _ => { return Err(crate::ErrorKind::OtherError( diff --git a/packages/app-lib/src/api/instance/shared/diff.rs b/packages/app-lib/src/api/instance/shared/diff.rs index 84483bce32a..84ff23bfe8e 100644 --- a/packages/app-lib/src/api/instance/shared/diff.rs +++ b/packages/app-lib/src/api/instance/shared/diff.rs @@ -266,6 +266,7 @@ fn shared_loader_label(loader: &LoaderReference) -> String { "fabric" => "Fabric", "quilt" => "Quilt", "neoforge" => "NeoForge", + "ornithe" => "Ornithe", name => name, }; match loader.version.as_deref() { diff --git a/packages/app-lib/src/api/pack/import/mmc.rs b/packages/app-lib/src/api/pack/import/mmc.rs index 662971c3666..8d9cc6a1d94 100644 --- a/packages/app-lib/src/api/pack/import/mmc.rs +++ b/packages/app-lib/src/api/pack/import/mmc.rs @@ -265,6 +265,24 @@ pub async fn import_mmc( Ok(()) } +/// Whether the instance's intermediary patch comes from Ornithe. +/// +/// The Ornithe installer adds a regular Fabric Loader component, so the +/// intermediary patch is the only thing that sets its instances apart. +async fn has_ornithe_intermediary(minecraft_folder: &Path) -> bool { + let Some(instance_folder) = minecraft_folder.parent() else { + return false; + }; + + io::read( + instance_folder + .join("patches") + .join("net.fabricmc.intermediary.json"), + ) + .await + .is_ok_and(|x| String::from_utf8_lossy(&x).contains("net.ornithemc")) +} + async fn import_mmc_unmanaged( instance_id: &str, minecraft_folder: PathBuf, @@ -274,6 +292,12 @@ async fn import_mmc_unmanaged( reporter: InstallProgressReporter, details: InstallPhaseDetails, ) -> crate::Result<()> { + let is_ornithe = mmc_pack + .components + .iter() + .any(|x| x.uid.starts_with("net.fabricmc.fabric-loader")) + && has_ornithe_intermediary(&minecraft_folder).await; + // Pack dependencies stored in mmc-pack.json, we convert to .mrpack pack dependencies let dependencies = mmc_pack .components @@ -281,7 +305,11 @@ async fn import_mmc_unmanaged( .filter_map(|component| { if component.uid.starts_with("net.fabricmc.fabric-loader") { return Some(( - PackDependency::FabricLoader, + if is_ornithe { + PackDependency::OrnitheLoader + } else { + PackDependency::FabricLoader + }, component.version.clone().unwrap_or_default(), )); } @@ -297,6 +325,12 @@ async fn import_mmc_unmanaged( component.version.clone().unwrap_or_default(), )); } + if component.uid.starts_with("net.ornithemc.fabric-loader") { + return Some(( + PackDependency::OrnitheLoader, + component.version.clone().unwrap_or_default(), + )); + } if component.uid.starts_with("org.quiltmc.quilt-loader") { return Some(( PackDependency::QuiltLoader, diff --git a/packages/app-lib/src/api/pack/install_from.rs b/packages/app-lib/src/api/pack/install_from.rs index 0dfa24941b4..1b71d280090 100644 --- a/packages/app-lib/src/api/pack/install_from.rs +++ b/packages/app-lib/src/api/pack/install_from.rs @@ -84,6 +84,9 @@ pub enum PackDependency { #[serde(rename = "quilt-loader")] QuiltLoader, + #[serde(rename = "ornithe-loader")] + OrnitheLoader, + #[serde(rename = "minecraft")] Minecraft, } @@ -555,6 +558,10 @@ pub async fn set_instance_information( mod_loader = Some(ModLoader::Quilt); loader_version = Some(value); } + PackDependency::OrnitheLoader => { + mod_loader = Some(ModLoader::Ornithe); + loader_version = Some(value); + } PackDependency::Minecraft => game_version = Some(value), } } diff --git a/packages/app-lib/src/launcher/args.rs b/packages/app-lib/src/launcher/args.rs index a482f66a8ff..4cbafa55791 100644 --- a/packages/app-lib/src/launcher/args.rs +++ b/packages/app-lib/src/launcher/args.rs @@ -144,7 +144,9 @@ pub fn get_jvm_arguments( java_arch, quick_play_type, )?; - } else { + } + + if !parsed_arguments.iter().any(|x| x == "-cp") { parsed_arguments.push(format!( "-Djava.library.path={}", canonicalize(natives_path) @@ -271,6 +273,24 @@ pub async fn get_minecraft_arguments( let profile = credentials.maybe_online_profile().await; let mut parsed_arguments = Vec::new(); + if let Some(legacy_arguments) = legacy_arguments { + for x in legacy_arguments.split(' ') { + parsed_arguments.push(parse_minecraft_argument( + &x.replace(' ', TEMPORARY_REPLACE_CHAR), + &access_token, + &profile.name, + profile.id, + version, + asset_index_name, + game_directory, + assets_directory, + version_type, + resolution, + quick_play_type, + )?); + } + } + if let Some(arguments) = arguments { parse_arguments( arguments, @@ -293,22 +313,6 @@ pub async fn get_minecraft_arguments( java_arch, quick_play_type, )?; - } else if let Some(legacy_arguments) = legacy_arguments { - for x in legacy_arguments.split(' ') { - parsed_arguments.push(parse_minecraft_argument( - &x.replace(' ', TEMPORARY_REPLACE_CHAR), - &access_token, - &profile.name, - profile.id, - version, - asset_index_name, - game_directory, - assets_directory, - version_type, - resolution, - quick_play_type, - )?); - } } if let QuickPlayType::Server(server) = quick_play_type diff --git a/packages/app-lib/src/state/instance_types.rs b/packages/app-lib/src/state/instance_types.rs index 6a68b1aea72..c331cd00018 100644 --- a/packages/app-lib/src/state/instance_types.rs +++ b/packages/app-lib/src/state/instance_types.rs @@ -84,6 +84,7 @@ pub enum ModLoader { Fabric, Quilt, NeoForge, + Ornithe, } impl ModLoader { @@ -94,6 +95,7 @@ impl ModLoader { Self::Fabric => "fabric", Self::Quilt => "quilt", Self::NeoForge => "neoforge", + Self::Ornithe => "ornithe", } } @@ -104,6 +106,7 @@ impl ModLoader { Self::Fabric => "fabric", Self::Quilt => "quilt", Self::NeoForge => "neo", + Self::Ornithe => "ornithe", } } @@ -114,6 +117,7 @@ impl ModLoader { "fabric" => Self::Fabric, "quilt" => Self::Quilt, "neoforge" => Self::NeoForge, + "ornithe" => Self::Ornithe, _ => Self::Vanilla, } } @@ -154,7 +158,7 @@ impl ProjectType { pub fn get_from_loaders(loaders: Vec) -> Option { if loaders .iter() - .any(|x| ["fabric", "forge", "quilt", "neoforge"].contains(&&**x)) + .any(|x| ProjectType::Mod.get_loaders().contains(&&**x)) { Some(ProjectType::Mod) } else if loaders.iter().any(|x| x == "datapack") { @@ -219,7 +223,9 @@ impl ProjectType { pub fn get_loaders(&self) -> &'static [&'static str] { match self { - ProjectType::Mod => &["fabric", "forge", "quilt", "neoforge"], + ProjectType::Mod => { + &["fabric", "forge", "quilt", "neoforge", "ornithe"] + } ProjectType::DataPack => &["datapack"], ProjectType::ResourcePack => &["vanilla", "canvas", "minecraft"], ProjectType::ShaderPack => &["iris", "optifine"], diff --git a/packages/app-lib/src/state/instances/commands/embedded_content_metadata.rs b/packages/app-lib/src/state/instances/commands/embedded_content_metadata.rs index 070b544282b..b9aa83d4e85 100644 --- a/packages/app-lib/src/state/instances/commands/embedded_content_metadata.rs +++ b/packages/app-lib/src/state/instances/commands/embedded_content_metadata.rs @@ -179,7 +179,7 @@ fn inspect_mod( fn metadata_order(loader: Option) -> [ModMetadataKind; 5] { match loader { - Some(ModLoader::Fabric) => [ + Some(ModLoader::Fabric | ModLoader::Ornithe) => [ ModMetadataKind::Fabric, ModMetadataKind::Quilt, ModMetadataKind::NeoForge, diff --git a/packages/daedalus/src/minecraft.rs b/packages/daedalus/src/minecraft.rs index f31e08b1716..f7629ee1cde 100644 --- a/packages/daedalus/src/minecraft.rs +++ b/packages/daedalus/src/minecraft.rs @@ -286,7 +286,7 @@ pub struct LibraryExtract { pub exclude: Option>, } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] /// Information about the java version the game needs pub struct JavaVersion { diff --git a/packages/daedalus/src/modded.rs b/packages/daedalus/src/modded.rs index e4330f8635d..666e3cc6896 100644 --- a/packages/daedalus/src/modded.rs +++ b/packages/daedalus/src/modded.rs @@ -1,5 +1,6 @@ use crate::minecraft::{ - Argument, ArgumentType, Library, VersionInfo, VersionType, + Argument, ArgumentType, JavaVersion, Library, LoggingConfiguration, + LoggingSide, VersionInfo, VersionType, }; use chrono::{DateTime, NaiveDateTime, Utc}; use serde::{Deserialize, Deserializer, Serialize}; @@ -13,6 +14,8 @@ pub const CURRENT_FORGE_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; +/// The latest version of the format the ornithe model structs deserialize to +pub const CURRENT_ORNITHE_FORMAT_VERSION: usize = 0; /// Metadata for locating and caching a loader manifest. #[derive(Debug, Clone)] @@ -73,6 +76,7 @@ fn current_loader_manifest_format_version(loader: &str) -> usize { "forge" => CURRENT_FORGE_FORMAT_VERSION, "quilt" => CURRENT_QUILT_FORMAT_VERSION, "neo" => CURRENT_NEOFORGE_FORMAT_VERSION, + "ornithe" => CURRENT_ORNITHE_FORMAT_VERSION, _ => 0, } } @@ -105,6 +109,7 @@ where } DateTime::parse_from_rfc3339(&s) + .or_else(|_| DateTime::parse_from_str(&s, "%Y-%m-%dT%H:%M:%S%.f%z")) .map(|date| date.with_timezone(&Utc)) .or_else(|_| { NaiveDateTime::parse_from_str(&s, "%Y-%m-%dT%H:%M:%S%.f") @@ -138,6 +143,16 @@ pub struct PartialVersionInfo { pub arguments: Option>>, /// Libraries that the version depends on pub libraries: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + /// Library artifacts (`groupId:artifactId`) of the inherited version that + /// the loader drops + pub removed_libraries: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// The Java version the loader prefers over the one the game asks for + pub java_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + /// The logging configuration the loader uses in place of the game's + pub logging: Option>, #[serde(rename = "type")] /// The type of version pub type_: VersionType, @@ -181,6 +196,10 @@ pub fn merge_partial_version( let lib_artifact = lib.name.rsplit_once(':').map(|x| x.0); if let Some(lib_artifact) = lib_artifact { + if partial.removed_libraries.iter().any(|x| x == lib_artifact) { + continue; + } + if !partial.libraries.iter().any(|x| { let target_artifact = x.name.rsplit_once(':').map(|x| x.0); @@ -195,8 +214,27 @@ pub fn merge_partial_version( } } + let partial_arguments = partial.arguments.map(|args| { + args.into_iter() + .filter(|(_, arguments)| !arguments.is_empty()) + .map(|(type_, arguments)| { + let arguments = arguments + .into_iter() + .map(|argument| match argument { + Argument::Normal(value) => Argument::Normal( + value.replace(DUMMY_REPLACE_STRING, &merge_id), + ), + ruled => ruled, + }) + .collect(); + + (type_, arguments) + }) + .collect::>() + }); + VersionInfo { - arguments: if let Some(partial_args) = partial.arguments { + arguments: if let Some(partial_args) = partial_arguments { if let Some(merge_args) = merge.arguments { let mut new_map = HashMap::new(); @@ -229,7 +267,7 @@ pub fn merge_partial_version( assets: merge.assets, downloads: merge.downloads, id: partial.id.replace(DUMMY_REPLACE_STRING, &merge_id), - java_version: merge.java_version, + java_version: partial.java_version.or(merge.java_version), libraries: libraries .into_iter() .chain(partial.libraries) @@ -239,13 +277,15 @@ pub fn merge_partial_version( x }) .collect::>(), - logging: merge.logging, + logging: partial.logging.filter(|x| !x.is_empty()).or(merge.logging), main_class: if let Some(main_class) = partial.main_class { main_class } else { merge.main_class }, - minecraft_arguments: partial.minecraft_arguments, + minecraft_arguments: partial + .minecraft_arguments + .or(merge.minecraft_arguments), minimum_launcher_version: merge.minimum_launcher_version, release_time: partial.release_time, time: partial.time, @@ -304,7 +344,8 @@ pub struct LoaderVersion { #[cfg(test)] mod tests { - use super::PartialVersionInfo; + use super::{PartialVersionInfo, merge_partial_version}; + use crate::minecraft::{Argument, ArgumentType, VersionInfo}; use chrono::{DateTime, Utc}; use serde_json::json; @@ -330,6 +371,8 @@ mod tests { ("2026-05-27T14:13:59.123+0:00", "2026-05-27T14:13:59.123Z"), ("2026-05-27T14:13:59+00:00", "2026-05-27T14:13:59Z"), ("2026-05-27T14:13:59+05:30", "2026-05-27T08:43:59Z"), + ("2026-05-27T14:13:59+0200", "2026-05-27T12:13:59Z"), + ("2026-05-27T14:13:59+0000", "2026-05-27T14:13:59Z"), ("2026-05-27T14:13:59Z", "2026-05-27T14:13:59Z"), ("2026-05-27T14:13:59", "2026-05-27T14:13:59Z"), ("2026-05-27T14:13:59.123", "2026-05-27T14:13:59.123Z"), @@ -341,6 +384,193 @@ mod tests { } } + #[test] + fn merge_loader_arguments_into_legacy_version() { + let partial: PartialVersionInfo = serde_json::from_value(json!({ + "id": "fabric-loader-0.19.5-${modrinth.gameVersion}-ornithe-gen2", + "inheritsFrom": "${modrinth.gameVersion}-vanilla", + "logging": { "client": { + "argument": "-Dlog4j.configurationFile=${path}", + "file": { "id": "client-1.12.xml", "sha1": "", "size": 888, "url": "" }, + "type": "log4j2-xml" + } }, + "time": "2026-05-27T14:13:59+0200", + "releaseTime": "2026-05-27T14:13:59+0200", + "arguments": { + "game": [], + "jvm": ["-Dfabric.gameVersion=${modrinth.gameVersion}"] + }, + "libraries": [ + { "name": "net.ornithemc:calamus-intermediary-gen2:${modrinth.gameVersion}" }, + { "name": "com.google.code.gson:gson:2.10" }, + { "name": "org.lwjgl.lwjgl:lwjgl:2.9.4+legacyfabric.15" }, + { + "name": "org.lwjgl.lwjgl:lwjgl-platform:2.9.4+legacyfabric.15", + "natives": { "osx-arm64": "natives-osx" } + } + ], + "type": "release" + })) + .unwrap(); + let vanilla: VersionInfo = serde_json::from_value(json!({ + "assetIndex": { "id": "1.8", "sha1": "", "size": 0, "totalSize": 0, "url": "" }, + "assets": "1.8", + "downloads": {}, + "id": "1.8.9", + "javaVersion": { "component": "jre-legacy", "majorVersion": 8 }, + "libraries": [ + { "name": "com.google.code.gson:gson:2.2.4" }, + { "name": "org.lwjgl.lwjgl:lwjgl:2.9.4-nightly-20150209" }, + { "name": "org.lwjgl.lwjgl:lwjgl:2.9.2-nightly-20140822" }, + { "name": "org.lwjgl.lwjgl:lwjgl-platform:2.9.2-nightly-20140822" } + ], + "mainClass": "net.minecraft.client.main.Main", + "minecraftArguments": "--username ${auth_player_name}", + "minimumLauncherVersion": 14, + "releaseTime": "2015-12-03T09:24:39+00:00", + "time": "2015-12-03T09:24:39+00:00", + "type": "release" + })) + .unwrap(); + + let merged = merge_partial_version(partial, vanilla); + let arguments = merged.arguments.unwrap(); + + assert!(!arguments.contains_key(&ArgumentType::Game)); + assert!(matches!( + &arguments[&ArgumentType::Jvm][..], + [Argument::Normal(x)] if x == "-Dfabric.gameVersion=1.8.9" + )); + assert_eq!( + merged.minecraft_arguments.as_deref(), + Some("--username ${auth_player_name}") + ); + assert_eq!( + merged + .libraries + .iter() + .map(|x| x.name.as_str()) + .collect::>(), + [ + "net.ornithemc:calamus-intermediary-gen2:1.8.9", + "com.google.code.gson:gson:2.10", + "org.lwjgl.lwjgl:lwjgl:2.9.4+legacyfabric.15", + "org.lwjgl.lwjgl:lwjgl-platform:2.9.4+legacyfabric.15" + ] + ); + assert_eq!(merged.java_version.unwrap().major_version, 8); + assert!(merged.logging.is_some()); + } + + #[test] + fn loader_removed_libraries_are_dropped() { + let partial: PartialVersionInfo = serde_json::from_value(json!({ + "id": "fabric-loader-0.19.5-1.5.2-ornithe-gen2", + "inheritsFrom": "1.5.2", + "time": "2026-05-27T14:13:59+00:00", + "releaseTime": "2026-05-27T14:13:59+00:00", + "libraries": [{ "name": "org.ow2.asm:asm:9.10.1" }], + "removedLibraries": ["org.ow2.asm:asm-all"], + "type": "release" + })) + .unwrap(); + let vanilla: VersionInfo = serde_json::from_value(json!({ + "assetIndex": { "id": "pre-1.6", "sha1": "", "size": 0, "totalSize": 0, "url": "" }, + "assets": "pre-1.6", + "downloads": {}, + "id": "1.5.2", + "libraries": [ + { "name": "net.minecraft:launchwrapper:1.5" }, + { "name": "org.ow2.asm:asm-all:4.1" } + ], + "mainClass": "net.minecraft.launchwrapper.Launch", + "minimumLauncherVersion": 13, + "releaseTime": "2013-04-25T15:45:00+00:00", + "time": "2013-04-25T15:45:00+00:00", + "type": "release" + })) + .unwrap(); + + let merged = merge_partial_version(partial, vanilla); + + assert_eq!( + merged + .libraries + .iter() + .map(|x| x.name.as_str()) + .collect::>(), + ["net.minecraft:launchwrapper:1.5", "org.ow2.asm:asm:9.10.1"] + ); + } + + #[test] + fn loader_java_version_overrides_vanilla() { + let partial: PartialVersionInfo = serde_json::from_value(json!({ + "id": "fabric-loader-0.19.5-1.8.9-ornithe-gen2", + "inheritsFrom": "1.8.9", + "javaVersion": { "component": "java-runtime-epsilon", "majorVersion": 25 }, + "time": "2026-05-27T14:13:59+00:00", + "releaseTime": "2026-05-27T14:13:59+00:00", + "libraries": [], + "type": "release" + })) + .unwrap(); + let vanilla: VersionInfo = serde_json::from_value(json!({ + "assetIndex": { "id": "1.8", "sha1": "", "size": 0, "totalSize": 0, "url": "" }, + "assets": "1.8", + "downloads": {}, + "id": "1.8.9", + "javaVersion": { "component": "jre-legacy", "majorVersion": 8 }, + "libraries": [], + "mainClass": "net.minecraft.client.main.Main", + "minimumLauncherVersion": 14, + "releaseTime": "2015-12-03T09:24:39+00:00", + "time": "2015-12-03T09:24:39+00:00", + "type": "release" + })) + .unwrap(); + + let merged = merge_partial_version(partial, vanilla); + + assert_eq!(merged.java_version.unwrap().major_version, 25); + } + + #[test] + fn empty_loader_logging_keeps_vanilla_logging() { + let partial: PartialVersionInfo = serde_json::from_value(json!({ + "id": "1.20.1-forge-47.4.0", + "inheritsFrom": "1.20.1", + "logging": {}, + "time": "2026-05-27T14:13:59+00:00", + "releaseTime": "2026-05-27T14:13:59+00:00", + "libraries": [], + "type": "release" + })) + .unwrap(); + let vanilla: VersionInfo = serde_json::from_value(json!({ + "assetIndex": { "id": "5", "sha1": "", "size": 0, "totalSize": 0, "url": "" }, + "assets": "5", + "downloads": {}, + "id": "1.20.1", + "logging": { "client": { + "argument": "-Dlog4j.configurationFile=${path}", + "file": { "id": "client-1.12.xml", "sha1": "", "size": 888, "url": "" }, + "type": "log4j2-xml" + } }, + "libraries": [], + "mainClass": "net.minecraft.client.main.Main", + "minimumLauncherVersion": 21, + "releaseTime": "2023-06-12T13:25:51+00:00", + "time": "2023-06-12T13:25:51+00:00", + "type": "release" + })) + .unwrap(); + + let merged = merge_partial_version(partial, vanilla); + + assert_eq!(merged.logging.map(|x| x.len()), Some(1)); + } + #[test] fn reject_invalid_loader_timestamps() { for input in [