From 10745e07a20deaf8353f30ecdde00bf911f1668f Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Tue, 13 Oct 2026 13:08:20 +0100 Subject: [PATCH] feat: ornithe --- apps/daedalus_client/src/forge.rs | 3 + apps/modrinth-minecraft-meta/README.md | 10 + .../src/export/fabriclike.rs | 13 +- .../modrinth-minecraft-meta/src/export/mod.rs | 12 + .../src/export/ornithe.rs | 9 + .../src/import/download.rs | 50 +- .../src/import/extract/fabric.rs | 4 + .../src/import/extract/fabriclike.rs | 217 +++++- .../src/import/extract/mod.rs | 12 + .../src/import/extract/ornithe.rs | 276 ++++++++ .../src/import/extract/quilt.rs | 4 + .../modrinth-minecraft-meta/src/import/mod.rs | 2 + .../src/import/ornithe.rs | 560 +++++++++++++++ apps/modrinth-minecraft-meta/src/lib.rs | 9 +- .../src/model/fabriclike.rs | 47 +- apps/modrinth-minecraft-meta/src/model/mod.rs | 2 + .../src/model/ornithe.rs | 26 + .../src/upstream/mod.rs | 3 + .../src/upstream/ornithe.rs | 83 +++ .../toasty/history.toml | 5 + .../migrations/20261013_120000_migration.sql | 9 + .../snapshots/20261013_120000_snapshot.toml | 648 ++++++++++++++++++ packages/daedalus/src/minecraft.rs | 2 +- packages/daedalus/src/modded.rs | 53 +- 24 files changed, 2025 insertions(+), 34 deletions(-) create mode 100644 apps/modrinth-minecraft-meta/src/export/ornithe.rs create mode 100644 apps/modrinth-minecraft-meta/src/import/extract/ornithe.rs create mode 100644 apps/modrinth-minecraft-meta/src/import/ornithe.rs create mode 100644 apps/modrinth-minecraft-meta/src/model/ornithe.rs create mode 100644 apps/modrinth-minecraft-meta/src/upstream/ornithe.rs create mode 100644 apps/modrinth-minecraft-meta/toasty/migrations/20261013_120000_migration.sql create mode 100644 apps/modrinth-minecraft-meta/toasty/snapshots/20261013_120000_snapshot.toml 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/modrinth-minecraft-meta/README.md b/apps/modrinth-minecraft-meta/README.md index 1fdfaf16380..f091c7e83ce 100644 --- a/apps/modrinth-minecraft-meta/README.md +++ b/apps/modrinth-minecraft-meta/README.md @@ -45,6 +45,16 @@ Forge and NeoForge currently import installer metadata and embedded artifacts, a Forge and NeoForge (henceforth Forgelike) store much of their important info in the installer JAR file, rather than as a JSON file served at some HTTP route. +## Ornithe + +Ornithe runs Fabric Loader on game versions up to 1.14.4, with its own intermediary (`net.ornithemc:calamus-intermediary-gen2`). Its profiles look like Fabric's, so it is extracted and exported through the same code, but a single template doesn't work: +- Ornithe adds different libraries depending on the game version (e.g. a newer log4j, or LWJGL builds from Legacy Fabric's Maven), which aren't in its profiles. Game versions are grouped by these libraries, and profiles are downloaded once per group, templated from the group's longest game version. The libraries are added to every profile of their group. +- The LWJGL builds come from Ornithe's copy of each version manifest. They keep their upstream downloads, like Mojang's libraries. +- Ornithe's version manifests also drop some of Mojang's libraries (e.g. `org.ow2.asm:asm-all`). These are listed in the profile's `removedLibraries`, and split groups without changing which profile is downloaded. +- Group IDs are hashed the same way as the legacy daedalus client, so they stay stable when switching over. +- A few game versions are named differently by Ornithe and Mojang (e.g. `1.0.0` and `1.0`). These get a group of their own whose profile isn't templated, since the app would fill the placeholder with Mojang's name. +- Only game versions in Mojang's catalog are exported, so Mojang metadata must be extracted first. + ## Fabric and Quilt Fabric's and Quilt's manifests contain (among other things) two fields: diff --git a/apps/modrinth-minecraft-meta/src/export/fabriclike.rs b/apps/modrinth-minecraft-meta/src/export/fabriclike.rs index cf67bda7d3d..6fe990fecd0 100644 --- a/apps/modrinth-minecraft-meta/src/export/fabriclike.rs +++ b/apps/modrinth-minecraft-meta/src/export/fabriclike.rs @@ -115,6 +115,9 @@ pub async fn export( FabriclikeLoader::Quilt => { format!("quilt/v{format_version}/manifest.json") } + FabriclikeLoader::Ornithe => { + format!("ornithe/v{format_version}/manifest.json") + } }; app.public_blobs .put(&path, &json, ContentType::Json) @@ -163,8 +166,11 @@ fn prepare_profile( mut profile: FabriclikeProfileMetadata, public_maven: &Url, ) -> FabriclikeProfileMetadata { + // libraries with their own downloads are served from upstream for library in &mut profile.libraries { - library.url = Some(public_maven.to_string()); + if library.downloads.is_none() { + library.url = Some(public_maven.to_string()); + } } profile } @@ -232,6 +238,7 @@ fn profile_path( let loader = match loader { FabriclikeLoader::Fabric => "fabric", FabriclikeLoader::Quilt => "quilt", + FabriclikeLoader::Ornithe => "ornithe", }; match group { None => format!("{loader}/v{format_version}/versions/{version}.json"), @@ -335,6 +342,10 @@ mod tests { profile_base_url: "https://meta.example.com/versions/loader/" .parse() .unwrap(), + templated: true, + extra_libraries: Vec::new(), + removed_libraries: Vec::new(), + java_version: None, } } diff --git a/apps/modrinth-minecraft-meta/src/export/mod.rs b/apps/modrinth-minecraft-meta/src/export/mod.rs index 3af08163975..b223b322d6d 100644 --- a/apps/modrinth-minecraft-meta/src/export/mod.rs +++ b/apps/modrinth-minecraft-meta/src/export/mod.rs @@ -11,6 +11,7 @@ mod fabric; mod fabriclike; mod forgelike; mod mojang; +mod ornithe; mod quilt; pub async fn run(app: AppState, upstreams: Upstreams) -> Result<()> { @@ -48,6 +49,17 @@ pub async fn run(app: AppState, upstreams: Upstreams) -> Result<()> { .ok(); } + if upstreams.ornithe { + ornithe::export(&app) + .context(info_span!("exporting Ornithe artifacts")) + .await + .inspect_err(|err| { + warn!("error: {err:?}"); + failed.push("Ornithe"); + }) + .ok(); + } + if upstreams.forge || upstreams.neoforge { forgelike::export(&app, upstreams) .context(info_span!("exporting Forge-like artifacts")) diff --git a/apps/modrinth-minecraft-meta/src/export/ornithe.rs b/apps/modrinth-minecraft-meta/src/export/ornithe.rs new file mode 100644 index 00000000000..75972d7bbc3 --- /dev/null +++ b/apps/modrinth-minecraft-meta/src/export/ornithe.rs @@ -0,0 +1,9 @@ +use anyhow::Result; + +use crate::{AppState, export::fabriclike, model::FabriclikeLoader}; + +pub const FORMAT_VERSION: u32 = 0; + +pub async fn export(app: &AppState) -> Result<()> { + fabriclike::export(app, FabriclikeLoader::Ornithe, FORMAT_VERSION).await +} diff --git a/apps/modrinth-minecraft-meta/src/import/download.rs b/apps/modrinth-minecraft-meta/src/import/download.rs index 3a7f02c736f..5d0de14272e 100644 --- a/apps/modrinth-minecraft-meta/src/import/download.rs +++ b/apps/modrinth-minecraft-meta/src/import/download.rs @@ -10,7 +10,7 @@ use tracing_anyhow::FutureContext; use crate::{ AppState, - import::{fabric, forge, mojang, neoforge, quilt}, + import::{fabric, forge, mojang, neoforge, ornithe, quilt}, model::{self, DownloadRunId}, store::BlobCas, upstream::Upstreams, @@ -61,6 +61,7 @@ pub async fn download_from_upstreams( forge: false, neoforge: false, quilt: false, + ornithe: false, }; if upstreams.mojang { @@ -98,6 +99,15 @@ pub async fn download_from_upstreams( completed.quilt = download_succeeded(result, &cx.errors, errors_before); } + if upstreams.ornithe { + let errors_before = cx.errors.len(); + let result = ornithe::download(&mut cx) + .context(info_span!("downloading Ornithe upstream")) + .await; + completed.ornithe = + download_succeeded(result, &cx.errors, errors_before); + } + // do Forge last since we have to download a lot of installers if upstreams.forge { let errors_before = cx.errors.len(); @@ -162,11 +172,41 @@ impl<'cx> DownloadRunContext<'cx> { url: impl IntoUrl, ) -> Result<(T, Sha256)> { let url = url.into_url().context("converting to URL")?; + let response = self + .http + .get(url.clone()) + .send() + .context(info_span!("fetching text")) + .await?; + self.store_json(url, response).await + } + + /// Like [`Self::download_json`], but returns [`None`] if the server + /// responds with 404 Not Found. + pub async fn download_json_if_found( + &self, + url: impl IntoUrl, + ) -> Result> { + let url = url.into_url().context("converting to URL")?; + let response = self + .http + .get(url.clone()) + .send() + .context(info_span!("fetching text")) + .await?; + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + self.store_json(url, response).await.map(Some) + } + + async fn store_json( + &self, + url: reqwest::Url, + response: reqwest::Response, + ) -> Result<(T, Sha256)> { let text = async { - self.http - .get(url.clone()) - .send() - .await? + response .error_for_status_ext() .await? .text() diff --git a/apps/modrinth-minecraft-meta/src/import/extract/fabric.rs b/apps/modrinth-minecraft-meta/src/import/extract/fabric.rs index b7ce0b627c9..a4989819ea4 100644 --- a/apps/modrinth-minecraft-meta/src/import/extract/fabric.rs +++ b/apps/modrinth-minecraft-meta/src/import/extract/fabric.rs @@ -117,6 +117,10 @@ pub async fn extract(app: &AppState) -> Result<()> { "{}/loader/", fabric::CATALOG_URL ))?, + templated: true, + extra_libraries: Vec::new(), + removed_libraries: Vec::new(), + java_version: None, }; fabriclike::extract_catalogs( diff --git a/apps/modrinth-minecraft-meta/src/import/extract/fabriclike.rs b/apps/modrinth-minecraft-meta/src/import/extract/fabriclike.rs index 6ff9f0bddf0..1cb5540248b 100644 --- a/apps/modrinth-minecraft-meta/src/import/extract/fabriclike.rs +++ b/apps/modrinth-minecraft-meta/src/import/extract/fabriclike.rs @@ -12,6 +12,7 @@ use crate::{ model::{ FabriclikeLoader, FabriclikeStoredArtifact, GAME_VERSION_PLACEHOLDER, }, + upstream::mojang::Argument, util::{MavenCoordinate, ResponseExt, Sha1, Sha256, from_json_slice}, }; @@ -132,11 +133,20 @@ pub async fn extract_catalogs( let bytes = app.cas.get(sha256) .context(info_span!("fetching loader profile", loader_version = %version.id, %source)) .await?; - let profile = from_json_slice::(&bytes) + let mut profile = from_json_slice::(&bytes) .context("parsing loader profile")?; + profile + .libraries + .extend(group.extra_libraries.iter().cloned()); + if let Some(java_version) = &group.java_version { + profile.java_version = Some(java_version.clone()); + } + profile + .removed_libraries + .clone_from(&group.removed_libraries); let (profile, dependencies) = normalize_profile( profile, - template, + group.templated.then_some(template), &group.games, fallback_maven, ) @@ -409,26 +419,64 @@ fn validate_groups( Ok(()) } +/// Replaces whole occurrences of `game_version` in `value` with +/// [`GAME_VERSION_PLACEHOLDER`], so `1.8` is left alone in `1.8.9`. +fn template_game_version(value: &str, game_version: &str) -> 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(GAME_VERSION_PLACEHOLDER); + copied = end; + } + } + templated.push_str(&value[copied..]); + templated +} + /// Takes a downloaded loader profile, replaces its game-specific metadata with /// placeholders, retaining source repositories for later publication. /// +/// Without a `template`, the profile is kept as downloaded, for a group with a +/// single game which the upstream names differently. +/// /// Returns the processed profile and the concrete artifacts needed by its games. /// Callers should dedup artifacts by Maven path before mirroring them. +/// Libraries with their own downloads are served from upstream, so they are +/// not returned. pub fn normalize_profile( mut profile: ProfileMetadata, - template: &str, + template: Option<&str>, games: &[String], fallback_maven: &Url, ) -> Result<(ProfileMetadata, Vec)> { - if let Some(prefix) = profile.id.strip_suffix(template) { - profile.id = format!("{prefix}{GAME_VERSION_PLACEHOLDER}"); + if let Some(template) = template { + profile.id = template_game_version(&profile.id, template); + profile.inherits_from = + template_game_version(&profile.inherits_from, template); + for argument in profile + .arguments + .iter_mut() + .flat_map(|arguments| arguments.values_mut()) + .flatten() + { + if let Argument::Normal(value) = argument { + *value = template_game_version(value, template); + } + } } - profile.inherits_from = profile - .inherits_from - .replace(template, GAME_VERSION_PLACEHOLDER); let mut artifacts = Vec::new(); for (index, library) in profile.libraries.iter_mut().enumerate() { + if library.downloads.is_some() { + continue; + } let name = library.name.clone(); let coordinate = name.parse::().with_context(|| { @@ -462,13 +510,17 @@ pub fn normalize_profile( // we only rewrite the coordinate if it's one of the ones // that we know contains a game version // (like fabric intermediary coordinate). - if matches!( - ( - coordinate.group_id.as_str(), - coordinate.artifact_id.as_str() - ), - ("net.fabricmc", "intermediary") | ("org.quiltmc", "hashed") - ) { + if let Some(template) = template + && matches!( + ( + coordinate.group_id.as_str(), + coordinate.artifact_id.as_str() + ), + ("net.fabricmc", "intermediary") + | ("org.quiltmc", "hashed") + | ("net.ornithemc", "calamus-intermediary-gen2") + ) + { ensure!( coordinate.version.as_str() == template, "library {index} mapping version {} does not match template {template:?}", @@ -588,6 +640,10 @@ mod tests { profile_base_url: "https://meta.example.com/versions/loader/" .parse() .unwrap(), + templated: true, + extra_libraries: Vec::new(), + removed_libraries: Vec::new(), + java_version: None, } } @@ -765,7 +821,7 @@ mod tests { } let (profile, artifacts) = normalize_profile( serde_json::from_value(profile)?, - "GAME", + Some("GAME"), games, &Url::parse("https://maven.fabricmc.net/").unwrap(), )?; @@ -785,7 +841,7 @@ mod tests { .unwrap(); let (profile, _) = normalize_profile( profile, - "26.3", + Some("26.3"), &[], &Url::parse("https://maven.quiltmc.org/repository/release/") .unwrap(), @@ -991,6 +1047,133 @@ mod tests { assert!(serde_json::from_value::(missing).is_err()); } + #[test] + fn game_versions_inside_longer_versions_are_not_templated() { + assert_eq!( + template_game_version( + "fabric-loader-0.11.8-1.8-ornithe-gen2", + "1.8" + ), + format!( + "fabric-loader-0.11.8-{GAME_VERSION_PLACEHOLDER}-ornithe-gen2" + ) + ); + assert_eq!( + template_game_version("-Dfabric.gameVersion=1.8", "1.8"), + format!("-Dfabric.gameVersion={GAME_VERSION_PLACEHOLDER}") + ); + assert_eq!(template_game_version("a:b:1.8.9", "1.8"), "a:b:1.8.9"); + assert_eq!(template_game_version("0.1.8", "1.8"), "0.1.8"); + } + + fn ornithe_profile() -> Value { + json!({ + "id": "fabric-loader-0.19.5-GAME-ornithe-gen2", + "inheritsFrom": "GAME-vanilla", + "releaseTime": "2026-10-13T09:08:32+0200", + "time": "2026-10-13T09:08:32+0200", + "type": "release", + "arguments": { + "jvm": ["-Dfabric.fixPackageAccess=true", "-Dfabric.gameVersion=GAME"], + "game": [] + }, + "logging": { "client": { + "argument": "-Dlog4j.configurationFile=${path}", + "file": { + "id": "client-1.12.xml", + "sha1": "bd65e7d2e3c237be76cfbef4c2405033d7f91521", + "size": 888, + "url": "https://launcher.mojang.com/v1/objects/bd65e7d2e3c237be76cfbef4c2405033d7f91521/client-1.12.xml" + }, + "type": "log4j2-xml" + } }, + "libraries": [ + { + "name": "net.ornithemc:calamus-intermediary-gen2:GAME", + "url": "https://maven.ornithemc.net/releases/" + }, + { + "name": "org.lwjgl.lwjgl:lwjgl-platform:2.9.4+legacyfabric.15", + "downloads": { "classifiers": { "natives-osx": { + "sha1": "0000000000000000000000000000000000000000", + "size": 1, + "url": "https://maven.legacyfabric.net/natives-osx.jar" + } } }, + "natives": { "osx": "natives-osx" } + } + ] + }) + } + + #[test] + fn templates_ornithe_profiles() { + let (profile, artifacts) = normalize_profile( + serde_json::from_value(ornithe_profile()).unwrap(), + Some("GAME"), + &["1.8.9".into(), "1.6.4".into()], + &Url::parse("https://maven.ornithemc.net/releases/").unwrap(), + ) + .unwrap(); + let profile = serde_json::to_value(profile).unwrap(); + assert_eq!( + profile["id"], + format!( + "fabric-loader-0.19.5-{GAME_VERSION_PLACEHOLDER}-ornithe-gen2" + ) + ); + assert_eq!( + profile["inheritsFrom"], + format!("{GAME_VERSION_PLACEHOLDER}-vanilla") + ); + assert_eq!( + profile["arguments"]["jvm"][1], + format!("-Dfabric.gameVersion={GAME_VERSION_PLACEHOLDER}") + ); + assert_eq!( + profile["libraries"][0]["name"], + format!( + "net.ornithemc:calamus-intermediary-gen2:{GAME_VERSION_PLACEHOLDER}" + ) + ); + assert_eq!( + profile["logging"]["client"]["file"]["id"], + "client-1.12.xml" + ); + assert_eq!( + artifacts + .iter() + .map(|artifact| artifact.coordinate.to_string()) + .collect::>(), + [ + "net.ornithemc:calamus-intermediary-gen2:1.8.9", + "net.ornithemc:calamus-intermediary-gen2:1.6.4" + ] + ); + } + + #[test] + fn untemplated_profiles_are_kept_as_downloaded() { + let (profile, artifacts) = normalize_profile( + serde_json::from_value(ornithe_profile()).unwrap(), + None, + &["1.0".into()], + &Url::parse("https://maven.ornithemc.net/releases/").unwrap(), + ) + .unwrap(); + let profile = serde_json::to_value(profile).unwrap(); + assert_eq!(profile["id"], "fabric-loader-0.19.5-GAME-ornithe-gen2"); + assert_eq!(profile["arguments"]["jvm"][1], "-Dfabric.gameVersion=GAME"); + assert_eq!( + profile["libraries"][0]["name"], + "net.ornithemc:calamus-intermediary-gen2:GAME" + ); + assert_eq!(artifacts.len(), 1); + assert_eq!( + artifacts[0].coordinate.to_string(), + "net.ornithemc:calamus-intermediary-gen2:GAME" + ); + } + #[test] fn artifact_urls_upgrade_http_repositories() { let artifact = Artifact { diff --git a/apps/modrinth-minecraft-meta/src/import/extract/mod.rs b/apps/modrinth-minecraft-meta/src/import/extract/mod.rs index 2220e6ba7d2..b007f0a7392 100644 --- a/apps/modrinth-minecraft-meta/src/import/extract/mod.rs +++ b/apps/modrinth-minecraft-meta/src/import/extract/mod.rs @@ -4,6 +4,7 @@ pub mod fabric; pub mod fabriclike; pub mod mojang; +pub mod ornithe; pub mod quilt; use anyhow::{Result, ensure}; @@ -47,6 +48,17 @@ pub async fn extract(app: &AppState, upstreams: Upstreams) -> Result<()> { .ok(); } + if upstreams.ornithe { + ornithe::extract(app) + .context(info_span!("extracting Ornithe metadata")) + .await + .inspect_err(|err| { + warn!("error: {err:?}"); + failed.push("Ornithe"); + }) + .ok(); + } + ensure!( failed.is_empty(), "metadata extraction failed for {}", diff --git a/apps/modrinth-minecraft-meta/src/import/extract/ornithe.rs b/apps/modrinth-minecraft-meta/src/import/extract/ornithe.rs new file mode 100644 index 00000000000..055109d06e7 --- /dev/null +++ b/apps/modrinth-minecraft-meta/src/import/extract/ornithe.rs @@ -0,0 +1,276 @@ +use std::collections::{HashMap, HashSet}; + +use anyhow::{Context, Result}; +use futures::{StreamExt, TryStreamExt, stream}; +use indexmap::IndexMap; +use tracing::{debug, info, info_span}; +use tracing_anyhow::FutureContext; +use url::Url; + +use crate::{ + AppState, + import::{ + extract::fabriclike::{ + self, CatalogGame, CatalogLoader, CatalogSnapshot, ProfileGroup, + }, + ornithe::{ + GameLibraries, libraries_url, mojang_game_version, profile_groups, + version_manifest_url, + }, + }, + model::{self, FabriclikeLoader}, + upstream::ornithe, + util::{Sha256, from_json_slice}, +}; + +pub async fn extract(app: &AppState) -> Result<()> { + let mut conn = app + .db + .connection() + .context(info_span!("acquiring connection")) + .await?; + let mut rows = model::OrnitheCatalog::all() + .exec(&mut conn) + .context(info_span!("fetching Ornithe catalogs")) + .await?; + info!( + num_catalogs = rows.len(), + "found downloaded Ornithe catalogs" + ); + + // fetch download runs where we downloaded these catalogs from; + // newer catalogs take precedence over older ones + let runs = model::DownloadRun::all() + .exec(&mut conn) + .context(info_span!("fetching download runs")) + .await? + .into_iter() + .map(|run| (run.id, run.started_at)) + .collect::>(); + rows.sort_by_key(|row| { + ( + std::cmp::Reverse(runs.get(&row.download_run_id).copied()), + row.id.0, + ) + }); + + // the app only launches game versions from Mojang's catalog; + // unpatched manifests tell which libraries Ornithe removes + let mojang_manifests = model::MinecraftVersion::all() + .exec(&mut conn) + .context(info_span!("fetching extracted Minecraft versions")) + .await? + .into_iter() + .map(|version| (version.name.0, version.source_manifest_sha256)) + .collect::>(); + + let mut games = IndexMap::new(); + let mut snapshots = Vec::with_capacity(rows.len()); + for row in &rows { + let catalog_games = from_json_slice::>( + &app.cas + .get(row.games_sha256) + .context(info_span!("fetching Ornithe game versions", sha256 = %row.games_sha256)) + .await?, + ) + .context("parsing Ornithe game versions")?; + let catalog_loaders = from_json_slice::>( + &app.cas + .get(row.loaders_sha256) + .context(info_span!("fetching Ornithe loader versions", sha256 = %row.loaders_sha256)) + .await?, + ) + .context("parsing Ornithe loader versions")?; + + for game in &catalog_games { + games + .entry(game.version.clone()) + .or_insert_with(|| game.clone()); + } + snapshots.push(( + row.download_run_id, + row.games_sha256, + catalog_games, + catalog_loaders, + )); + } + + let run_priority = rows + .iter() + .enumerate() + .map(|(priority, row)| (row.download_run_id, priority)) + .collect::>(); + let urls = games + .keys() + .flat_map(|game| { + [libraries_url(game), version_manifest_url(game)] + .map(|url| url.to_string()) + }) + .collect::>(); + let mut downloads = model::BlobDownload::all() + .filter(model::BlobDownload::fields().url().in_list(urls)) + .exec(&mut conn) + .context(info_span!("fetching downloaded game libraries")) + .await?; + drop(conn); + downloads.sort_by_key(|row| { + ( + run_priority + .get(&row.download_run_id) + .copied() + .unwrap_or(usize::MAX), + row.download_run_id.0, + ) + }); + let mut hashes = HashMap::::new(); + for download in downloads { + hashes.entry(download.url).or_insert(download.sha256); + } + + // games Ornithe has no version manifest for are never downloaded + let available = games + .keys() + .filter_map(|game| { + let libraries = *hashes.get(libraries_url(game).as_str())?; + let manifest = *hashes.get(version_manifest_url(game).as_str())?; + Some((game.clone(), libraries, manifest)) + }) + .collect::>(); + info!( + num_games = available.len(), + "reading downloaded game libraries" + ); + let mojang_manifests = &mojang_manifests; + let game_libraries = stream::iter(available) + .map(|(game, libraries, manifest)| async move { + let libraries = from_json_slice( + &app.cas + .get(libraries) + .context(info_span!("fetching game libraries", %game)) + .await?, + ) + .context("parsing game libraries")?; + let manifest = from_json_slice( + &app.cas + .get(manifest) + .context( + info_span!("fetching game version manifest", %game), + ) + .await?, + ) + .context("parsing game version manifest")?; + let mojang_libraries = match mojang_manifests + .get(&mojang_game_version(&game)) + { + Some(sha256) => { + from_json_slice::( + &app.cas + .get(*sha256) + .context( + info_span!("fetching Mojang version manifest", %game), + ) + .await?, + ) + .context("parsing Mojang version manifest")? + .libraries + } + None => Vec::new(), + }; + anyhow::Ok(GameLibraries::new( + game, + libraries, + manifest, + &mojang_libraries, + )) + }) + .buffered(app.concurrency.download.get()) + .try_collect::>() + .await?; + + let profile_base_url = Url::parse(ornithe::PROFILE_BASE_URL)?; + let groups = profile_groups(game_libraries) + .into_iter() + .filter_map(|group| { + let fallback_templates = if group.templated { + group + .games + .iter() + .filter(|game| **game != group.template) + .map(ToString::to_string) + .collect() + } else { + Vec::new() + }; + let games = group + .games + .iter() + .map(mojang_game_version) + .filter(|game| mojang_manifests.contains_key(game)) + .collect::>(); + if games.is_empty() { + debug!(group = %group.id, "skipping group without Mojang game versions"); + return None; + } + Some(ProfileGroup { + id: Some(group.id), + template: group.template.to_string(), + fallback_templates, + games, + profile_base_url: profile_base_url.clone(), + templated: group.templated, + extra_libraries: group + .libraries + .into_iter() + .map(Into::into) + .collect(), + removed_libraries: group.removed_libraries, + java_version: group.java_version, + }) + }) + .collect::>(); + let grouped_games = groups + .iter() + .flat_map(|group| group.games.iter().cloned()) + .collect::>(); + + let catalogs = snapshots + .into_iter() + .map( + |(download_run_id, sha256, catalog_games, catalog_loaders)| { + CatalogSnapshot { + download_run_id, + sha256, + games: catalog_games + .into_iter() + .map(|game| CatalogGame { + id: mojang_game_version(&game.version), + stable: game.stable, + }) + .filter(|game| grouped_games.contains(&game.id)) + .collect(), + loaders: catalog_loaders + .into_iter() + .map(|loader| CatalogLoader { + id: loader.version.to_string(), + stable: loader.stable, + }) + .collect(), + mappings: Vec::new(), + } + }, + ) + .collect(); + + fabriclike::extract_catalogs( + app, + FabriclikeLoader::Ornithe, + &Url::parse(ornithe::MAVEN_URL)?, + catalogs, + groups, + ) + .context(info_span!( + "extracting catalogs", + loader = ?FabriclikeLoader::Ornithe + )) + .await +} diff --git a/apps/modrinth-minecraft-meta/src/import/extract/quilt.rs b/apps/modrinth-minecraft-meta/src/import/extract/quilt.rs index 223e900479d..afbecb9df84 100644 --- a/apps/modrinth-minecraft-meta/src/import/extract/quilt.rs +++ b/apps/modrinth-minecraft-meta/src/import/extract/quilt.rs @@ -132,6 +132,10 @@ pub async fn extract(app: &AppState) -> Result<()> { .map(|game| game.to_string()) .collect(), profile_base_url: profile_base_url.clone(), + templated: true, + extra_libraries: Vec::new(), + removed_libraries: Vec::new(), + java_version: None, }) .collect(); diff --git a/apps/modrinth-minecraft-meta/src/import/mod.rs b/apps/modrinth-minecraft-meta/src/import/mod.rs index 50fc0862675..fa5dafa1844 100644 --- a/apps/modrinth-minecraft-meta/src/import/mod.rs +++ b/apps/modrinth-minecraft-meta/src/import/mod.rs @@ -11,6 +11,7 @@ pub mod forge; pub mod forgelike; pub mod mojang; pub mod neoforge; +pub mod ornithe; pub mod quilt; pub async fn run(app: AppState, upstreams: Upstreams) -> Result<()> { @@ -23,6 +24,7 @@ pub async fn run(app: AppState, upstreams: Upstreams) -> Result<()> { (upstreams.forge, completed.forge, "Forge"), (upstreams.neoforge, completed.neoforge, "NeoForge"), (upstreams.quilt, completed.quilt, "Quilt"), + (upstreams.ornithe, completed.ornithe, "Ornithe"), ] .into_iter() .filter(|(selected, completed, _)| *selected && !completed) diff --git a/apps/modrinth-minecraft-meta/src/import/ornithe.rs b/apps/modrinth-minecraft-meta/src/import/ornithe.rs new file mode 100644 index 00000000000..e18cce7f448 --- /dev/null +++ b/apps/modrinth-minecraft-meta/src/import/ornithe.rs @@ -0,0 +1,560 @@ +use std::{ + collections::{BTreeSet, HashMap, HashSet}, + sync::atomic::{self, AtomicUsize}, +}; + +use anyhow::{Result, ensure}; +use futures::{StreamExt, stream}; +use tracing::{debug, info, info_span}; +use tracing_anyhow::FutureContext; +use url::Url; + +use crate::{ + import::download::DownloadRunContext, + model, + upstream::{ + mojang::{JavaVersion, Library}, + ornithe::{ + GAMES_URL, GameLoaderProfile, GameVersion, GameVersionName, + LIBRARIES_BASE_URL, LOADERS_URL, LoaderVersion, LoaderVersionName, + PROFILE_BASE_URL, VERSION_MANIFEST_BASE_URL, VersionManifest, + is_lwjgl_upgrade, + }, + }, + util::Sha1, +}; + +/// 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"), +]; + +/// Releases whose pre-releases Mojang named ` Pre-Release `, +/// where Ornithe uses `-pre`. +const SPACED_PRE_RELEASES: &[&str] = &["1.14", "1.14.1", "1.14.2"]; + +/// Game versions which share the same libraries, and so the same profile. +#[derive(Debug, Clone)] +pub struct MetadataGroup { + pub id: String, + pub template: GameVersionName, + pub templated: bool, + pub games: Vec, + /// Libraries added to every profile of the group, sorted by name. + pub libraries: Vec, + /// Artifacts (`groupId:artifactId`) of Mojang's libraries which Ornithe + /// drops. + pub removed_libraries: Vec, + /// The Java version Ornithe asks for, in place of the game's. + pub java_version: Option, +} + +/// Libraries Ornithe adds to and removes from a game version, which decide +/// its group. +#[derive(Debug, Clone)] +pub struct GameLibraries { + pub game: GameVersionName, + /// The libraries Ornithe's installer adds, and its LWJGL upgrades. + pub libraries: Vec, + pub removed_libraries: Vec, + pub java_version: Option, +} + +pub async fn download(cx: &mut DownloadRunContext<'_>) -> Result<()> { + let initial_errors = cx.errors.len(); + let (games, games_sha256) = cx + .download_json::>(GAMES_URL) + .context(info_span!("fetching game versions")) + .await?; + let (loaders, loaders_sha256) = cx + .download_json::>(LOADERS_URL) + .context(info_span!("fetching loader versions")) + .await?; + info!( + num_game_versions = games.len(), + num_loader_versions = loaders.len(), + "downloaded Ornithe catalog" + ); + + let num_total = games.len(); + let num_done = AtomicUsize::new(0); + let task = async |game: GameVersion| { + let libraries = download_game_libraries(cx, &game.version) + .context( + info_span!("downloading game libraries", game = %game.version), + ) + .await + .inspect_err(|err| cx.errors.push(err)) + .ok() + .flatten(); + + let num_done = num_done.fetch_add(1, atomic::Ordering::SeqCst) + 1; + if num_done.is_multiple_of(100) || num_done == num_total { + info!("downloaded {num_done}/{num_total} game library lists"); + } + libraries + }; + let game_libraries = stream::iter(games) + .map(task) + .buffered(cx.download_concurrency) + .filter_map(futures::future::ready) + .collect::>() + .await; + + let groups = profile_groups(game_libraries); + let template_pairs = groups + .iter() + .flat_map(|group| { + loaders + .iter() + .map(|loader| (&group.template, &loader.version)) + }) + .collect::>(); + let num_total = template_pairs.len(); + info!( + num_groups = groups.len(), + "downloading {num_total} loader profiles" + ); + + let num_done = AtomicUsize::new(0); + let task = + async |(game, loader): (&GameVersionName, &LoaderVersionName)| { + cx.download_json::(profile_url(game, loader)) + .await + .inspect_err(|err| cx.errors.push(err)) + .ok(); + + let num_done = num_done.fetch_add(1, atomic::Ordering::SeqCst) + 1; + if num_done.is_multiple_of(100) || num_done == num_total { + info!("downloaded {num_done}/{num_total} loader profiles"); + } + }; + stream::iter(template_pairs) + .for_each_concurrent(cx.download_concurrency, task) + .await; + + ensure!( + cx.errors.len() == initial_errors, + "Ornithe item downloads failed; catalog not registered" + ); + + toasty::create!(model::OrnitheCatalog { + download_run_id: cx.download_run_id, + games_sha256, + loaders_sha256, + }) + .exec(*cx.conn().await) + .context(info_span!("inserting catalog")) + .await?; + + Ok(()) +} + +/// Downloads what Ornithe adds to a game version, or [`None`] if Ornithe has +/// no version manifest for it. +async fn download_game_libraries( + cx: &DownloadRunContext<'_>, + game: &GameVersionName, +) -> Result> { + let Some((manifest, _)) = cx + .download_json_if_found::(version_manifest_url(game)) + .await? + else { + debug!(%game, "skipping game version without an Ornithe manifest"); + return Ok(None); + }; + let (libraries, _) = cx + .download_json::>(libraries_url(game)) + .await?; + Ok(Some(GameLibraries::new( + game.clone(), + libraries, + manifest, + &[], + ))) +} + +impl GameLibraries { + /// Combines the libraries Ornithe adds to `game` with its version + /// manifest, dropping the artifacts of `mojang_libraries` which the + /// manifest no longer has. + pub fn new( + game: GameVersionName, + mut libraries: Vec, + manifest: VersionManifest, + mojang_libraries: &[Library], + ) -> Self { + let removed_libraries = + removed_libraries(mojang_libraries, &manifest.libraries); + libraries + .extend(manifest.libraries.into_iter().filter(is_lwjgl_upgrade)); + libraries.sort_by(|a, b| a.name.cmp(&b.name)); + Self { + game, + libraries, + removed_libraries, + java_version: manifest.java_version, + } + } +} + +fn library_artifact(library: &Library) -> &str { + library + .name + .rsplit_once(':') + .map_or(library.name.as_str(), |(artifact, _)| artifact) +} + +/// The artifacts of Mojang's libraries which Ornithe's version manifest drops. +pub 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(|artifact| !ornithe_artifacts.contains(artifact)) + .collect::>() + .into_iter() + .map(str::to_owned) + .collect() +} + +fn url_with_segments(base: &str, segments: &[&str]) -> Url { + let mut url = Url::parse(base).expect("valid Ornithe base URL"); + url.path_segments_mut() + .expect("Ornithe URL supports path segments") + .pop_if_empty() + .extend(segments); + url +} + +pub fn profile_url(game: &GameVersionName, loader: &LoaderVersionName) -> Url { + url_with_segments( + PROFILE_BASE_URL, + &[game.0.as_str(), loader.0.as_str(), "profile", "json"], + ) +} + +pub fn libraries_url(game: &GameVersionName) -> Url { + url_with_segments(LIBRARIES_BASE_URL, &[game.0.as_str()]) +} + +pub fn version_manifest_url(game: &GameVersionName) -> Url { + url_with_segments(VERSION_MANIFEST_BASE_URL, &[&format!("{game}.json")]) +} + +/// The Mojang ID of the client JAR Ornithe names `game`. +pub fn mojang_game_version(game: &GameVersionName) -> String { + if let Some((mojang, _)) = GAME_VERSION_ALIASES + .iter() + .find(|(_, ornithe)| *ornithe == game.0) + { + return (*mojang).to_owned(); + } + if let Some((release, pre_release)) = game.0.split_once("-pre") + && SPACED_PRE_RELEASES.contains(&release) + && !pre_release.is_empty() + && pre_release.bytes().all(|x| x.is_ascii_digit()) + { + return format!("{release} Pre-Release {pre_release}"); + } + game.0.clone() +} + +/// Hashes what decides the profile of `game` into a group ID, matching the +/// IDs of the legacy daedalus client. +fn group_id(game: &GameLibraries, removed_libraries: &[String]) -> String { + let renamed = (mojang_game_version(&game.game) != game.game.0) + .then_some(&game.game.0); + let java = game.java_version.as_ref().map(|java_version| { + format!( + "java:{}:{}", + java_version.component, java_version.major_version + ) + }); + let mut libraries = game + .libraries + .iter() + .map(|library| library.name.clone()) + .collect::>(); + libraries.sort_unstable(); + let names = libraries + .into_iter() + .chain(renamed.cloned()) + .chain(java) + .chain(removed_libraries.iter().map(|x| format!("removed:{x}"))) + .collect::>(); + Sha1::from_digest(names.join("\n")).to_string()[..8].to_owned() +} + +/// Groups game versions by the libraries and Java version Ornithe gives them, +/// and the libraries it removes, preserving the order of `games`. +/// +/// Profiles of a group are downloaded for the longest game version which +/// shares its added libraries, so that templating it never touches a shorter +/// game version inside another string. Removed libraries don't change the +/// downloaded profile, so they split groups without changing the template. +/// Game versions Mojang names differently get their own untemplated group. +pub fn profile_groups( + games: impl IntoIterator, +) -> Vec { + let games = games.into_iter().collect::>(); + let mut templates = HashMap::::new(); + for game in &games { + templates + .entry(group_id(game, &[])) + .and_modify(|template| { + if game.game.0.len() >= template.0.len() { + *template = &game.game; + } + }) + .or_insert(&game.game); + } + let templates = templates + .into_iter() + .map(|(id, template)| (id, template.clone())) + .collect::>(); + + let mut groups = Vec::::new(); + for game in games { + let template = &templates[&group_id(&game, &[])]; + let id = group_id(&game, &game.removed_libraries); + let GameLibraries { + game, + libraries, + removed_libraries, + java_version, + } = game; + + if let Some(group) = groups.iter_mut().find(|group| group.id == id) { + group.games.push(game); + } else { + groups.push(MetadataGroup { + id, + template: template.clone(), + templated: mojang_game_version(&game) == game.0, + games: vec![game], + libraries, + removed_libraries, + java_version, + }); + } + } + groups +} + +#[cfg(test)] +mod tests { + use super::*; + + fn name(id: &str) -> GameVersionName { + GameVersionName(id.to_owned()) + } + + fn libraries(value: serde_json::Value) -> Vec { + serde_json::from_value(value).unwrap() + } + + fn game(id: &str, names: &[&str]) -> GameLibraries { + GameLibraries { + game: name(id), + libraries: libraries( + names + .iter() + .map(|name| serde_json::json!({ "name": name })) + .collect(), + ), + removed_libraries: Vec::new(), + java_version: None, + } + } + + #[test] + fn game_versions_with_the_same_libraries_share_a_group() { + let groups = profile_groups([ + game("1.8.9", &["a:a:1", "b:b:1"]), + game("13w16a-04192037", &["b:b:1", "a:a:1"]), + game("1.14.4", &["a:a:1"]), + ]); + + assert_eq!(groups.len(), 2); + assert_eq!(groups[0].games, [name("1.8.9"), name("13w16a-04192037")]); + assert_eq!(groups[0].template, name("13w16a-04192037")); + assert!(groups[0].templated); + assert_eq!(groups[1].games, [name("1.14.4")]); + assert_ne!(groups[0].id, groups[1].id); + } + + #[test] + fn manifests_add_lwjgl_upgrades_and_remove_dropped_libraries() { + let game = GameLibraries::new( + name("1.5.2"), + libraries(serde_json::json!([ + { "name": "org.slf4j:slf4j-api:2.0.17" }, + { "name": "com.google.code.gson:gson:2.10" } + ])), + VersionManifest { + libraries: libraries(serde_json::json!([ + { "name": "org.ow2.asm:asm:7.0" }, + { "name": "net.minecraft:launchwrapper:1.5" }, + { + "name": "org.lwjgl.lwjgl:lwjgl:2.9.4+legacyfabric.15", + "url": "https://maven.legacyfabric.net/" + } + ])), + java_version: None, + }, + &libraries(serde_json::json!([ + { "name": "net.minecraft:launchwrapper:1.5" }, + { "name": "org.ow2.asm:asm-all:4.1" }, + { "name": "org.lwjgl.lwjgl:lwjgl:2.9.0" }, + { "name": "org.lwjgl.lwjgl:lwjgl:2.9.1-nightly-20130708-debug3" } + ])), + ); + + assert_eq!( + game.libraries + .iter() + .map(|library| library.name.as_str()) + .collect::>(), + [ + "com.google.code.gson:gson:2.10", + "org.lwjgl.lwjgl:lwjgl:2.9.4+legacyfabric.15", + "org.slf4j:slf4j-api:2.0.17" + ] + ); + assert_eq!(game.removed_libraries, ["org.ow2.asm:asm-all"]); + } + + #[test] + fn group_ids_match_the_legacy_daedalus_client() { + let mut game = game("1.5.2", &["b:b:1", "a:a:1"]); + game.java_version = Some(JavaVersion { + component: "jre-legacy".to_owned(), + major_version: 8, + }); + game.removed_libraries = vec!["org.ow2.asm:asm-all".to_owned()]; + + assert_eq!(profile_groups([game])[0].id, "4237e563"); + } + + #[test] + fn removed_libraries_split_groups_without_changing_templates() { + let mut dropping_asm_all = game("1.5.2", &["a:a:1"]); + dropping_asm_all.removed_libraries = + vec!["org.ow2.asm:asm-all".to_owned()]; + let groups = profile_groups([ + dropping_asm_all, + game("13w16a-04192037", &["a:a:1"]), + ]); + + assert_eq!(groups.len(), 2); + assert_eq!(groups[0].removed_libraries, ["org.ow2.asm:asm-all"]); + assert!(groups[1].removed_libraries.is_empty()); + assert_ne!(groups[0].id, groups[1].id); + assert_eq!(groups[0].template, name("13w16a-04192037")); + assert_eq!(groups[1].template, name("13w16a-04192037")); + } + + #[test] + fn java_versions_split_groups() { + let mut modern = game("1.12.2", &["a:a:1"]); + modern.java_version = Some(JavaVersion { + component: "java-runtime-epsilon".to_owned(), + major_version: 25, + }); + let groups = profile_groups([game("1.8.9", &["a:a:1"]), modern]); + + assert_eq!(groups.len(), 2); + assert!(groups[0].java_version.is_none()); + assert_eq!( + groups[1].java_version.as_ref().map(|x| x.major_version), + Some(25) + ); + assert_ne!(groups[0].id, groups[1].id); + } + + #[test] + fn renamed_game_versions_get_an_untemplated_group() { + let groups = profile_groups([ + game("1.0.1", &["a:a:1"]), + game("1.0.0", &["a:a:1"]), + game("1.14.2-pre4", &["a:a:1"]), + ]); + + assert_eq!(groups.len(), 3); + assert!(groups[0].templated); + assert_eq!(groups[1].games, [name("1.0.0")]); + assert!(!groups[1].templated); + assert_eq!(groups[2].template, name("1.14.2-pre4")); + assert!(!groups[2].templated); + } + + #[test] + fn maps_ornithe_ids_to_mojang_ids() { + for (ornithe, mojang) in [ + ("1.0.0", "1.0"), + ("c0.30-c-renew", "c0.30_01c"), + ("1.14-pre1", "1.14 Pre-Release 1"), + ("1.14.2-pre4", "1.14.2 Pre-Release 4"), + ("1.14.2-pre4-270720", "1.14.2-pre4-270720"), + ("1.14.3-pre1", "1.14.3-pre1"), + ("1.8.9", "1.8.9"), + ] { + assert_eq!(mojang_game_version(&name(ornithe)), mojang); + } + } + + #[test] + fn urls_match_ornithe_meta() { + assert_eq!( + profile_url(&name("1.8.9"), &LoaderVersionName("0.19.5".into())) + .as_str(), + "https://meta.ornithemc.net/v3/versions/gen2/fabric-loader/1.8.9/0.19.5/profile/json" + ); + assert_eq!( + libraries_url(&name("b1.7.3")).as_str(), + "https://meta.ornithemc.net/v3/versions/gen2/libraries/b1.7.3" + ); + assert_eq!( + version_manifest_url(&name("1.8.9")).as_str(), + "https://ornithemc.net/mc-versions/gen2/version/manifest/1.8.9.json" + ); + } + + #[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": "0000000000000000000000000000000000000000", + "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": "0000000000000000000000000000000000000000", + "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/modrinth-minecraft-meta/src/lib.rs b/apps/modrinth-minecraft-meta/src/lib.rs index 60b336706a9..ac7c402bd81 100644 --- a/apps/modrinth-minecraft-meta/src/lib.rs +++ b/apps/modrinth-minecraft-meta/src/lib.rs @@ -42,6 +42,9 @@ struct Cli { /// Process Quilt loader info? #[arg(long)] quilt: bool, + /// Process Ornithe loader info? + #[arg(long)] + ornithe: bool, #[command(subcommand)] command: Command, } @@ -129,13 +132,15 @@ pub async fn main() -> Result<()> { && !cli.fabric && !cli.forge && !cli.neoforge - && !cli.quilt; + && !cli.quilt + && !cli.ornithe; Upstreams { mojang: all_upstreams || cli.mojang, fabric: all_upstreams || cli.fabric, forge: all_upstreams || cli.forge, neoforge: all_upstreams || cli.neoforge, quilt: all_upstreams || cli.quilt, + ornithe: all_upstreams || cli.ornithe, } }; @@ -186,7 +191,7 @@ mod tests { .unwrap(); assert!(matches!(cli.command, Command::Import)); assert!(cli.fabric && cli.quilt); - assert!(!cli.mojang && !cli.forge && !cli.neoforge); + assert!(!cli.mojang && !cli.forge && !cli.neoforge && !cli.ornithe); let cli = Cli::try_parse_from(["minecraft-meta", "export"]).unwrap(); assert!(matches!(cli.command, Command::Export)); } diff --git a/apps/modrinth-minecraft-meta/src/model/fabriclike.rs b/apps/modrinth-minecraft-meta/src/model/fabriclike.rs index 1da69e32613..a034b3b54f6 100644 --- a/apps/modrinth-minecraft-meta/src/model/fabriclike.rs +++ b/apps/modrinth-minecraft-meta/src/model/fabriclike.rs @@ -8,8 +8,9 @@ use url::Url; use crate::{ model::{Processor, SidedDataEntry}, upstream::mojang::{ - Argument, ArgumentType, LibraryDownloads, LibraryExtract, - OperatingSystem, Rule, VersionType, + Argument, ArgumentType, JavaVersion, Library, LibraryDownloads, + LibraryExtract, LoggingConfiguration, LoggingSide, OperatingSystem, + Rule, VersionType, }, util::{MavenCoordinate, Sha256}, }; @@ -20,6 +21,7 @@ pub const GAME_VERSION_PLACEHOLDER: &str = "${modrinth.gameVersion}"; pub enum FabriclikeLoader { Fabric, Quilt, + Ornithe, } /// Selected games, profile groups, and concrete dependencies for one loader. @@ -40,7 +42,7 @@ pub struct FabriclikeCatalog { #[key(loader, version_group, loader_version)] pub struct FabriclikeProfile { pub loader: FabriclikeLoader, - /// Empty for Fabric's universal group; Quilt uses named groups. + /// Empty for Fabric's universal group; Quilt and Ornithe use named groups. pub version_group: String, pub loader_version: String, pub stable: bool, @@ -66,6 +68,23 @@ pub struct FabriclikeProfileGroup { pub fallback_templates: Vec, pub games: Vec, pub profile_base_url: Url, + /// Whether the template game version is replaced with a placeholder. + /// + /// Groups for a single game whose upstream name differs from Mojang's keep + /// the upstream name, since the placeholder would be filled with Mojang's. + #[serde(default = "default_true")] + pub templated: bool, + /// Libraries added to every profile in this group. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub extra_libraries: Vec, + /// Artifacts (`groupId:artifactId`) of the game's libraries which every + /// profile in this group drops. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub removed_libraries: Vec, + /// Java version every profile in this group asks for, in place of the + /// game's. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub java_version: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -98,12 +117,18 @@ pub struct FabriclikeProfileMetadata { #[serde(skip_serializing_if = "Option::is_none")] pub arguments: Option>>, pub libraries: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub removed_libraries: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub java_version: Option, #[serde(rename = "type")] pub ty: VersionType, #[serde(skip_serializing_if = "Option::is_none")] pub data: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub processors: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub logging: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -130,3 +155,19 @@ pub struct FabriclikeLibrary { fn default_true() -> bool { true } + +impl From for FabriclikeLibrary { + fn from(library: Library) -> Self { + Self { + downloads: library.downloads, + extract: library.extract, + name: library.name, + url: library.url, + natives: library.natives, + rules: library.rules, + checksums: library.checksums, + include_in_classpath: library.include_in_classpath, + downloadable: library.downloadable, + } + } +} diff --git a/apps/modrinth-minecraft-meta/src/model/mod.rs b/apps/modrinth-minecraft-meta/src/model/mod.rs index 3bc739400c1..196f0df47c1 100644 --- a/apps/modrinth-minecraft-meta/src/model/mod.rs +++ b/apps/modrinth-minecraft-meta/src/model/mod.rs @@ -7,6 +7,7 @@ mod forgelike; mod minecraft; mod mojang; mod neoforge; +mod ornithe; mod profile; mod quilt; @@ -17,5 +18,6 @@ pub use forgelike::*; pub use minecraft::*; pub use mojang::*; pub use neoforge::*; +pub use ornithe::*; pub use profile::*; pub use quilt::*; diff --git a/apps/modrinth-minecraft-meta/src/model/ornithe.rs b/apps/modrinth-minecraft-meta/src/model/ornithe.rs new file mode 100644 index 00000000000..2e801e524ee --- /dev/null +++ b/apps/modrinth-minecraft-meta/src/model/ornithe.rs @@ -0,0 +1,26 @@ +use toasty::{Embed, Model}; +use uuid::Uuid; + +use crate::{ + model::{DownloadRun, DownloadRunId}, + util::Sha256, +}; + +/// ID of an [`OrnitheCatalog`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Embed)] +pub struct OrnitheCatalogId(pub Uuid); + +/// Ornithe game and loader version lists downloaded during some +/// [`DownloadRun`]. +#[derive(Debug, Clone, Model)] +pub struct OrnitheCatalog { + #[key] + #[auto] + pub id: OrnitheCatalogId, + #[unique] + pub download_run_id: DownloadRunId, + #[belongs_to] + pub download_run: toasty::Deferred, + pub games_sha256: Sha256, + pub loaders_sha256: Sha256, +} diff --git a/apps/modrinth-minecraft-meta/src/upstream/mod.rs b/apps/modrinth-minecraft-meta/src/upstream/mod.rs index 2d7500a1cac..e873a996008 100644 --- a/apps/modrinth-minecraft-meta/src/upstream/mod.rs +++ b/apps/modrinth-minecraft-meta/src/upstream/mod.rs @@ -5,6 +5,7 @@ pub mod fabric; pub mod forge; pub mod mojang; pub mod neoforge; +pub mod ornithe; pub mod quilt; #[derive(Debug, Clone, Copy)] @@ -14,6 +15,7 @@ pub struct Upstreams { pub forge: bool, pub neoforge: bool, pub quilt: bool, + pub ornithe: bool, } impl Default for Upstreams { @@ -24,6 +26,7 @@ impl Default for Upstreams { forge: true, neoforge: true, quilt: true, + ornithe: true, } } } diff --git a/apps/modrinth-minecraft-meta/src/upstream/ornithe.rs b/apps/modrinth-minecraft-meta/src/upstream/ornithe.rs new file mode 100644 index 00000000000..4093c86407a --- /dev/null +++ b/apps/modrinth-minecraft-meta/src/upstream/ornithe.rs @@ -0,0 +1,83 @@ +use derive_more::Display; +use serde::{Deserialize, Serialize}; +use url::Url; + +use crate::upstream::mojang::{JavaVersion, Library}; + +pub const GAMES_URL: &str = "https://meta.ornithemc.net/v3/versions/gen2/game"; +pub const LOADERS_URL: &str = + "https://meta.ornithemc.net/v3/versions/gen2/fabric-loader"; +pub const PROFILE_BASE_URL: &str = + "https://meta.ornithemc.net/v3/versions/gen2/fabric-loader/"; +pub const LIBRARIES_BASE_URL: &str = + "https://meta.ornithemc.net/v3/versions/gen2/libraries/"; +pub const VERSION_MANIFEST_BASE_URL: &str = + "https://ornithemc.net/mc-versions/gen2/version/manifest/"; +pub const MAVEN_URL: &str = "https://maven.ornithemc.net/releases/"; + +/// Host of the LWJGL builds which Ornithe's version manifests swap in for +/// Mojang's. +pub const LWJGL_UPGRADE_HOST: &str = "maven.legacyfabric.net"; + +#[derive( + Debug, Display, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, +)] +pub struct GameVersionName(pub String); + +/// Entry of the game version list at [`GAMES_URL`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GameVersion { + pub version: GameVersionName, + pub stable: bool, +} + +#[derive( + Debug, Display, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, +)] +pub struct LoaderVersionName(pub String); + +/// Entry of the Fabric Loader version list at [`LOADERS_URL`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoaderVersion { + pub version: LoaderVersionName, + pub stable: bool, +} + +/// Ornithe's copy of a Mojang version manifest, served at +/// [`VERSION_MANIFEST_BASE_URL`]`{game_version}.json`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VersionManifest { + pub libraries: Vec, + #[serde(default)] + pub java_version: Option, +} + +/// Example URL: +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GameLoaderProfile { + pub id: String, +} + +/// Whether Ornithe swaps this library in for Mojang's LWJGL. +pub fn is_lwjgl_upgrade(library: &Library) -> bool { + let downloads = library.downloads.as_ref(); + downloads + .and_then(|downloads| downloads.artifact.as_ref()) + .into_iter() + .chain( + downloads + .and_then(|downloads| downloads.classifiers.as_ref()) + .into_iter() + .flat_map(|classifiers| classifiers.values()), + ) + .filter_map(|download| download.url.as_ref()) + .chain( + library + .url + .as_deref() + .and_then(|url| Url::parse(url).ok()) + .as_ref(), + ) + .any(|url| url.host_str() == Some(LWJGL_UPGRADE_HOST)) +} diff --git a/apps/modrinth-minecraft-meta/toasty/history.toml b/apps/modrinth-minecraft-meta/toasty/history.toml index c5c0470bb26..3563b7fd8ab 100644 --- a/apps/modrinth-minecraft-meta/toasty/history.toml +++ b/apps/modrinth-minecraft-meta/toasty/history.toml @@ -14,3 +14,8 @@ snapshot_name = "20261009_104246_snapshot.toml" id = 5673943660847592223 name = "20261009_155457_migration.sql" snapshot_name = "20261009_155457_snapshot.toml" + +[[migrations]] +id = 2484940804259544734 +name = "20261013_120000_migration.sql" +snapshot_name = "20261013_120000_snapshot.toml" diff --git a/apps/modrinth-minecraft-meta/toasty/migrations/20261013_120000_migration.sql b/apps/modrinth-minecraft-meta/toasty/migrations/20261013_120000_migration.sql new file mode 100644 index 00000000000..05c05f0a73e --- /dev/null +++ b/apps/modrinth-minecraft-meta/toasty/migrations/20261013_120000_migration.sql @@ -0,0 +1,9 @@ +ALTER TYPE "fabriclike_loader" ADD VALUE 'ornithe'; +CREATE TABLE "ornithe_catalogs" ( + "id" UUID NOT NULL, + "download_run_id" UUID NOT NULL, + "games_sha256" TEXT NOT NULL, + "loaders_sha256" TEXT NOT NULL, + PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX "index_ornithe_catalogs_by_download_run_id" ON "ornithe_catalogs" ("download_run_id"); diff --git a/apps/modrinth-minecraft-meta/toasty/snapshots/20261013_120000_snapshot.toml b/apps/modrinth-minecraft-meta/toasty/snapshots/20261013_120000_snapshot.toml new file mode 100644 index 00000000000..af65dd020f4 --- /dev/null +++ b/apps/modrinth-minecraft-meta/toasty/snapshots/20261013_120000_snapshot.toml @@ -0,0 +1,648 @@ +version = 1 + +[schema] + +[[schema.tables]] +id = 0 +name = "neoforge_catalogs" +primary_key = { columns = [{ table = 0, index = 0 }], index = { table = 0, index = 0 } } + +[[schema.tables.columns]] +id = { table = 0, index = 0 } +name = "id" +ty = "Uuid" +storage_ty = "Uuid" + +[[schema.tables.columns]] +id = { table = 0, index = 1 } +name = "download_run_id" +ty = "Uuid" +storage_ty = "Uuid" + +[[schema.tables.columns]] +id = { table = 0, index = 2 } +name = "forge_sha256" +ty = "String" +storage_ty = "Text" + +[[schema.tables.columns]] +id = { table = 0, index = 3 } +name = "neoforge_sha256" +ty = "String" +storage_ty = "Text" + +[[schema.tables.indices]] +id = { table = 0, index = 0 } +name = "index_neoforge_catalogs_by_id" +on = 0 +columns = [{ column = { table = 0, index = 0 }, op = "Eq", scope = "Partition" }] +unique = true +primary_key = true + +[[schema.tables.indices]] +id = { table = 0, index = 1 } +name = "index_neoforge_catalogs_by_download_run_id" +on = 0 +columns = [{ column = { table = 0, index = 1 }, op = "Eq", scope = "Partition" }] +unique = true + +[[schema.tables]] +id = 1 +name = "download_runs" +primary_key = { columns = [{ table = 1, index = 0 }], index = { table = 1, index = 0 } } + +[[schema.tables.columns]] +id = { table = 1, index = 0 } +name = "id" +ty = "Uuid" +storage_ty = "Uuid" + +[[schema.tables.columns]] +id = { table = 1, index = 1 } +name = "started_at" +ty = "Timestamp" +storage_ty = { Timestamp = 6 } + +[[schema.tables.columns]] +id = { table = 1, index = 2 } +name = "completed_at" +ty = "Timestamp" +storage_ty = { Timestamp = 6 } +nullable = true + +[[schema.tables.columns]] +id = { table = 1, index = 3 } +name = "errors" +ty = "String" +storage_ty = "Text" +nullable = true + +[[schema.tables.indices]] +id = { table = 1, index = 0 } +name = "index_download_runs_by_id" +on = 1 +columns = [{ column = { table = 1, index = 0 }, op = "Eq", scope = "Partition" }] +unique = true +primary_key = true + +[[schema.tables]] +id = 2 +name = "mojang_catalogs" +primary_key = { columns = [{ table = 2, index = 0 }], index = { table = 2, index = 0 } } + +[[schema.tables.columns]] +id = { table = 2, index = 0 } +name = "id" +ty = "Uuid" +storage_ty = "Uuid" + +[[schema.tables.columns]] +id = { table = 2, index = 1 } +name = "download_run_id" +ty = "Uuid" +storage_ty = "Uuid" + +[[schema.tables.columns]] +id = { table = 2, index = 2 } +name = "sha256" +ty = "String" +storage_ty = "Text" + +[[schema.tables.indices]] +id = { table = 2, index = 0 } +name = "index_mojang_catalogs_by_id" +on = 2 +columns = [{ column = { table = 2, index = 0 }, op = "Eq", scope = "Partition" }] +unique = true +primary_key = true + +[[schema.tables.indices]] +id = { table = 2, index = 1 } +name = "index_mojang_catalogs_by_download_run_id" +on = 2 +columns = [{ column = { table = 2, index = 1 }, op = "Eq", scope = "Partition" }] +unique = true + +[[schema.tables]] +id = 3 +name = "profiles" +primary_key = { columns = [{ table = 3, index = 0 }, { table = 3, index = 1 }, { table = 3, index = 2 }], index = { table = 3, index = 0 } } + +[[schema.tables.columns]] +id = { table = 3, index = 0 } +name = "loader" +ty = "String" +storage_ty = { Enum = { name = "minecraft_loader", variants = [{ name = "fabric" }, { name = "forge" }, { name = "neoforge" }, { name = "quilt" }] } } + +[[schema.tables.columns]] +id = { table = 3, index = 1 } +name = "minecraft_version" +ty = "String" +storage_ty = "Text" + +[[schema.tables.columns]] +id = { table = 3, index = 2 } +name = "loader_version" +ty = "String" +storage_ty = "Text" + +[[schema.tables.columns]] +id = { table = 3, index = 3 } +name = "metadata" +ty = "String" +storage_ty = "Json" + +[[schema.tables.indices]] +id = { table = 3, index = 0 } +name = "index_profiles_by_loader_and_minecraft_version_and_loader__cb6a" +on = 3 +columns = [{ column = { table = 3, index = 0 }, op = "Eq", scope = "Partition" }, { column = { table = 3, index = 1 }, op = "Eq", scope = "Local" }, { column = { table = 3, index = 2 }, op = "Eq", scope = "Local" }] +unique = true +primary_key = true + +[[schema.tables.indices]] +id = { table = 3, index = 1 } +name = "index_profiles_by_minecraft_version" +on = 3 +columns = [{ column = { table = 3, index = 1 }, op = "Eq", scope = "Partition" }] + +[[schema.tables]] +id = 4 +name = "blob_hashes" +primary_key = { columns = [{ table = 4, index = 0 }], index = { table = 4, index = 0 } } + +[[schema.tables.columns]] +id = { table = 4, index = 0 } +name = "sha256" +ty = "String" +storage_ty = "Text" + +[[schema.tables.columns]] +id = { table = 4, index = 1 } +name = "sha1" +ty = "String" +storage_ty = "Text" + +[[schema.tables.indices]] +id = { table = 4, index = 0 } +name = "index_blob_hashes_by_sha256" +on = 4 +columns = [{ column = { table = 4, index = 0 }, op = "Eq", scope = "Partition" }] +unique = true +primary_key = true + +[[schema.tables.indices]] +id = { table = 4, index = 1 } +name = "index_blob_hashes_by_sha1" +on = 4 +columns = [{ column = { table = 4, index = 1 }, op = "Eq", scope = "Partition" }] + +[[schema.tables]] +id = 5 +name = "blob_downloads" +primary_key = { columns = [{ table = 5, index = 0 }, { table = 5, index = 1 }], index = { table = 5, index = 0 } } + +[[schema.tables.columns]] +id = { table = 5, index = 0 } +name = "download_run_id" +ty = "Uuid" +storage_ty = "Uuid" + +[[schema.tables.columns]] +id = { table = 5, index = 1 } +name = "url" +ty = "String" +storage_ty = "Text" + +[[schema.tables.columns]] +id = { table = 5, index = 2 } +name = "sha256" +ty = "String" +storage_ty = "Text" + +[[schema.tables.indices]] +id = { table = 5, index = 0 } +name = "index_blob_downloads_by_download_run_id_and_url" +on = 5 +columns = [{ column = { table = 5, index = 0 }, op = "Eq", scope = "Partition" }, { column = { table = 5, index = 1 }, op = "Eq", scope = "Local" }] +unique = true +primary_key = true + +[[schema.tables.indices]] +id = { table = 5, index = 1 } +name = "index_blob_downloads_by_sha256" +on = 5 +columns = [{ column = { table = 5, index = 2 }, op = "Eq", scope = "Partition" }] + +[[schema.tables]] +id = 6 +name = "fabric_catalogs" +primary_key = { columns = [{ table = 6, index = 0 }], index = { table = 6, index = 0 } } + +[[schema.tables.columns]] +id = { table = 6, index = 0 } +name = "id" +ty = "Uuid" +storage_ty = "Uuid" + +[[schema.tables.columns]] +id = { table = 6, index = 1 } +name = "download_run_id" +ty = "Uuid" +storage_ty = "Uuid" + +[[schema.tables.columns]] +id = { table = 6, index = 2 } +name = "sha256" +ty = "String" +storage_ty = "Text" + +[[schema.tables.indices]] +id = { table = 6, index = 0 } +name = "index_fabric_catalogs_by_id" +on = 6 +columns = [{ column = { table = 6, index = 0 }, op = "Eq", scope = "Partition" }] +unique = true +primary_key = true + +[[schema.tables.indices]] +id = { table = 6, index = 1 } +name = "index_fabric_catalogs_by_download_run_id" +on = 6 +columns = [{ column = { table = 6, index = 1 }, op = "Eq", scope = "Partition" }] +unique = true + +[[schema.tables]] +id = 7 +name = "quilt_catalogs" +primary_key = { columns = [{ table = 7, index = 0 }], index = { table = 7, index = 0 } } + +[[schema.tables.columns]] +id = { table = 7, index = 0 } +name = "id" +ty = "Uuid" +storage_ty = "Uuid" + +[[schema.tables.columns]] +id = { table = 7, index = 1 } +name = "download_run_id" +ty = "Uuid" +storage_ty = "Uuid" + +[[schema.tables.columns]] +id = { table = 7, index = 2 } +name = "sha256" +ty = "String" +storage_ty = "Text" + +[[schema.tables.indices]] +id = { table = 7, index = 0 } +name = "index_quilt_catalogs_by_id" +on = 7 +columns = [{ column = { table = 7, index = 0 }, op = "Eq", scope = "Partition" }] +unique = true +primary_key = true + +[[schema.tables.indices]] +id = { table = 7, index = 1 } +name = "index_quilt_catalogs_by_download_run_id" +on = 7 +columns = [{ column = { table = 7, index = 1 }, op = "Eq", scope = "Partition" }] +unique = true + +[[schema.tables]] +id = 8 +name = "forgelike_installers" +primary_key = { columns = [{ table = 8, index = 0 }, { table = 8, index = 1 }], index = { table = 8, index = 0 } } + +[[schema.tables.columns]] +id = { table = 8, index = 0 } +name = "loader" +ty = "String" +storage_ty = { Enum = { name = "forgelike_loader", variants = [{ name = "forge" }, { name = "neoforge" }] } } + +[[schema.tables.columns]] +id = { table = 8, index = 1 } +name = "name" +ty = "String" +storage_ty = "Text" + +[[schema.tables.columns]] +id = { table = 8, index = 2 } +name = "download_run_id" +ty = "Uuid" +storage_ty = "Uuid" + +[[schema.tables.columns]] +id = { table = 8, index = 3 } +name = "sha256" +ty = "String" +storage_ty = "Text" + +[[schema.tables.indices]] +id = { table = 8, index = 0 } +name = "index_forgelike_installers_by_loader_and_name" +on = 8 +columns = [{ column = { table = 8, index = 0 }, op = "Eq", scope = "Partition" }, { column = { table = 8, index = 1 }, op = "Eq", scope = "Local" }] +unique = true +primary_key = true + +[[schema.tables.indices]] +id = { table = 8, index = 1 } +name = "index_forgelike_installers_by_sha256" +on = 8 +columns = [{ column = { table = 8, index = 3 }, op = "Eq", scope = "Partition" }] + +[[schema.tables]] +id = 9 +name = "forgelike_extracts" +primary_key = { columns = [{ table = 9, index = 0 }], index = { table = 9, index = 0 } } + +[[schema.tables.columns]] +id = { table = 9, index = 0 } +name = "installer_sha256" +ty = "String" +storage_ty = "Text" + +[[schema.tables.columns]] +id = { table = 9, index = 1 } +name = "created_at" +ty = "Timestamp" +storage_ty = { Timestamp = 6 } + +[[schema.tables.columns]] +id = { table = 9, index = 2 } +name = "metadata" +ty = "String" +storage_ty = "Json" + +[[schema.tables.columns]] +id = { table = 9, index = 3 } +name = "embedded_maven_artifacts" +ty = "String" +storage_ty = "Json" + +[[schema.tables.indices]] +id = { table = 9, index = 0 } +name = "index_forgelike_extracts_by_installer_sha256" +on = 9 +columns = [{ column = { table = 9, index = 0 }, op = "Eq", scope = "Partition" }] +unique = true +primary_key = true + +[[schema.tables]] +id = 10 +name = "forge_catalogs" +primary_key = { columns = [{ table = 10, index = 0 }], index = { table = 10, index = 0 } } + +[[schema.tables.columns]] +id = { table = 10, index = 0 } +name = "id" +ty = "Uuid" +storage_ty = "Uuid" + +[[schema.tables.columns]] +id = { table = 10, index = 1 } +name = "download_run_id" +ty = "Uuid" +storage_ty = "Uuid" + +[[schema.tables.columns]] +id = { table = 10, index = 2 } +name = "sha256" +ty = "String" +storage_ty = "Text" + +[[schema.tables.indices]] +id = { table = 10, index = 0 } +name = "index_forge_catalogs_by_id" +on = 10 +columns = [{ column = { table = 10, index = 0 }, op = "Eq", scope = "Partition" }] +unique = true +primary_key = true + +[[schema.tables.indices]] +id = { table = 10, index = 1 } +name = "index_forge_catalogs_by_download_run_id" +on = 10 +columns = [{ column = { table = 10, index = 1 }, op = "Eq", scope = "Partition" }] +unique = true + +[[schema.tables]] +id = 11 +name = "fabriclike_catalogs" +primary_key = { columns = [{ table = 11, index = 0 }], index = { table = 11, index = 0 } } + +[[schema.tables.columns]] +id = { table = 11, index = 0 } +name = "loader" +ty = "String" +storage_ty = { Enum = { name = "fabriclike_loader", variants = [{ name = "fabric" }, { name = "quilt" }, { name = "ornithe" }] } } + +[[schema.tables.columns]] +id = { table = 11, index = 1 } +name = "games" +ty = "String" +storage_ty = "Json" + +[[schema.tables.columns]] +id = { table = 11, index = 2 } +name = "groups" +ty = "String" +storage_ty = "Json" + +[[schema.tables.columns]] +id = { table = 11, index = 3 } +name = "artifacts" +ty = "String" +storage_ty = "Json" + +[[schema.tables.indices]] +id = { table = 11, index = 0 } +name = "index_fabriclike_catalogs_by_loader" +on = 11 +columns = [{ column = { table = 11, index = 0 }, op = "Eq", scope = "Partition" }] +unique = true +primary_key = true + +[[schema.tables]] +id = 12 +name = "fabriclike_profiles" +primary_key = { columns = [{ table = 12, index = 0 }, { table = 12, index = 1 }, { table = 12, index = 2 }], index = { table = 12, index = 0 } } + +[[schema.tables.columns]] +id = { table = 12, index = 0 } +name = "loader" +ty = "String" +storage_ty = { Enum = { name = "fabriclike_loader", variants = [{ name = "fabric" }, { name = "quilt" }, { name = "ornithe" }] } } + +[[schema.tables.columns]] +id = { table = 12, index = 1 } +name = "version_group" +ty = "String" +storage_ty = "Text" + +[[schema.tables.columns]] +id = { table = 12, index = 2 } +name = "loader_version" +ty = "String" +storage_ty = "Text" + +[[schema.tables.columns]] +id = { table = 12, index = 3 } +name = "stable" +ty = "Bool" +storage_ty = "Boolean" + +[[schema.tables.columns]] +id = { table = 12, index = 4 } +name = "position" +ty = "I32" +storage_ty = { Integer = 4 } + +[[schema.tables.columns]] +id = { table = 12, index = 5 } +name = "source_url" +ty = "String" +storage_ty = "Text" + +[[schema.tables.columns]] +id = { table = 12, index = 6 } +name = "source_sha256" +ty = "String" +storage_ty = "Text" + +[[schema.tables.columns]] +id = { table = 12, index = 7 } +name = "template" +ty = "String" +storage_ty = "Text" + +[[schema.tables.columns]] +id = { table = 12, index = 8 } +name = "metadata" +ty = "String" +storage_ty = "Json" + +[[schema.tables.indices]] +id = { table = 12, index = 0 } +name = "index_fabriclike_profiles_by_loader_and_version_group_and__8be5" +on = 12 +columns = [{ column = { table = 12, index = 0 }, op = "Eq", scope = "Partition" }, { column = { table = 12, index = 1 }, op = "Eq", scope = "Local" }, { column = { table = 12, index = 2 }, op = "Eq", scope = "Local" }] +unique = true +primary_key = true + +[[schema.tables]] +id = 13 +name = "minecraft_versions" +primary_key = { columns = [{ table = 13, index = 0 }], index = { table = 13, index = 0 } } + +[[schema.tables.columns]] +id = { table = 13, index = 0 } +name = "name" +ty = "String" +storage_ty = "Text" + +[[schema.tables.columns]] +id = { table = 13, index = 1 } +name = "catalog_entry" +ty = "String" +storage_ty = "Json" + +[[schema.tables.columns]] +id = { table = 13, index = 2 } +name = "manifest" +ty = "String" +storage_ty = "Json" + +[[schema.tables.columns]] +id = { table = 13, index = 3 } +name = "source_catalog_sha256" +ty = "String" +storage_ty = "Text" + +[[schema.tables.columns]] +id = { table = 13, index = 4 } +name = "source_manifest_sha256" +ty = "String" +storage_ty = "Text" + +[[schema.tables.columns]] +id = { table = 13, index = 5 } +name = "is_latest_release" +ty = "Bool" +storage_ty = "Boolean" +nullable = true + +[[schema.tables.columns]] +id = { table = 13, index = 6 } +name = "is_latest_snapshot" +ty = "Bool" +storage_ty = "Boolean" +nullable = true + +[[schema.tables.indices]] +id = { table = 13, index = 0 } +name = "index_minecraft_versions_by_name" +on = 13 +columns = [{ column = { table = 13, index = 0 }, op = "Eq", scope = "Partition" }] +unique = true +primary_key = true + +[[schema.tables.indices]] +id = { table = 13, index = 1 } +name = "index_minecraft_versions_by_is_latest_release" +on = 13 +columns = [{ column = { table = 13, index = 5 }, op = "Eq", scope = "Partition" }] +unique = true + +[[schema.tables.indices]] +id = { table = 13, index = 2 } +name = "index_minecraft_versions_by_is_latest_snapshot" +on = 13 +columns = [{ column = { table = 13, index = 6 }, op = "Eq", scope = "Partition" }] +unique = true + +[[schema.tables]] +id = 13 +name = "ornithe_catalogs" +primary_key = { columns = [{ table = 13, index = 0 }], index = { table = 13, index = 0 } } + +[[schema.tables.columns]] +id = { table = 13, index = 0 } +name = "id" +ty = "Uuid" +storage_ty = "Uuid" + +[[schema.tables.columns]] +id = { table = 13, index = 1 } +name = "download_run_id" +ty = "Uuid" +storage_ty = "Uuid" + +[[schema.tables.columns]] +id = { table = 13, index = 2 } +name = "games_sha256" +ty = "String" +storage_ty = "Text" + +[[schema.tables.columns]] +id = { table = 13, index = 3 } +name = "loaders_sha256" +ty = "String" +storage_ty = "Text" + +[[schema.tables.indices]] +id = { table = 13, index = 0 } +name = "index_ornithe_catalogs_by_id" +on = 13 +columns = [{ column = { table = 13, index = 0 }, op = "Eq", scope = "Partition" }] +unique = true +primary_key = true + +[[schema.tables.indices]] +id = { table = 13, index = 1 } +name = "index_ornithe_catalogs_by_download_run_id" +on = 13 +columns = [{ column = { table = 13, index = 1 }, op = "Eq", scope = "Partition" }] +unique = true 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 6d121b357ea..df1b0c14281 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, } } @@ -96,6 +100,10 @@ where let s = String::deserialize(deserializer)?; serde_json::from_str::>(&format!("\"{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") .map(|date| date.and_utc()) @@ -128,6 +136,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, @@ -171,6 +189,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); @@ -185,8 +207,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(); @@ -219,7 +260,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) @@ -229,13 +270,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,