//! Logic for launching Minecraft use crate::api::pack::import::direct_link::direct_link_group; use crate::data::ModLoader; use crate::event::emit::{emit_instance, emit_loading, init_loading}; use crate::event::{InstancePayloadType, LoadingBarType}; use crate::install::{ InstallJavaStep, InstallPhaseDetails, InstallPhaseId, InstallProgress, InstallProgressReporter, }; use crate::instance::QuickPlayType; pub use crate::launcher::direct_link::ExternalGameDirMode; pub(crate) use crate::launcher::direct_link::{ DirectLinkedLaunch, LinkedLauncherDialect, apply_hmcl_settings, conservative_launch_facts, external_version_dir_for_game_override, extract_linked_natives, hmcl_java_candidates, hmcl_with_global_fallback, merged_to_version_info, normalize_merged_loader_libraries, pcl_available_memory_gb, pcl_ram_profile, }; use crate::launcher::download::{LocalRuntimeSource, download_log_config}; use crate::launcher::instance_runtime::InstanceRuntimeAdapter; use crate::launcher::quick_play_version::{ QuickPlayServerVersion, QuickPlayVersion, }; use crate::server_address::{ServerAddress, parse_server_address}; use crate::state::server_join_log::JoinLogEntry; use crate::state::{ CacheBehaviour, Credentials, Instance, InstanceInstallStage, InstanceLaunchContext, InstanceLink, JavaVersion, MemorySettings, ProcessMetadata, WindowSize, }; use crate::util::io; use crate::util::rpc::RpcServerBuilder; use crate::{State, get_resource_file, process}; use base64::Engine; use base64::prelude::BASE64_STANDARD; use chrono::Utc; use daedalus as d; use daedalus::minecraft::{LoggingSide, RuleAction, VersionInfo}; use daedalus::modded::{LoaderVersion, Manifest}; use regex::Regex; use serde::Deserialize; use std::fmt::Write; use std::path::{Path, PathBuf}; use std::process::{Output, Stdio}; use std::time::Instant; use tokio::io::AsyncReadExt; use tokio::process::Command; use tokio_util::sync::CancellationToken; #[cfg(target_os = "windows")] use winreg::{RegKey, enums::HKEY_CURRENT_USER}; mod args; mod direct_ensure; mod direct_link; pub(crate) mod instance_runtime; mod local_artifact; mod natives; pub mod download; pub mod jvm_args; pub mod language; pub mod local_version; pub mod optifine; pub mod quick_play_version; const UTF8_GAME_ARGUMENT_PREFIX: &str = "__THESEUS_UTF8__:"; fn encode_game_argument(argument: String) -> String { if argument.is_ascii() && !argument.starts_with(UTF8_GAME_ARGUMENT_PREFIX) { argument } else { format!( "{UTF8_GAME_ARGUMENT_PREFIX}{}", BASE64_STANDARD.encode(argument.as_bytes()) ) } } #[cfg(target_os = "windows")] fn set_high_performance_gpu_preference( executable: impl AsRef, ) -> crate::Result<()> { const REGISTRY_PATH: &str = "Software\\Microsoft\\DirectX\\UserGpuPreferences"; const REGISTRY_VALUE: &str = "GpuPreference=2;"; let current_user = RegKey::predef(HKEY_CURRENT_USER); let (key, _) = current_user.create_subkey(REGISTRY_PATH)?; let executable = executable.as_ref().to_string_lossy(); if key .get_value::(executable.as_ref()) .ok() .as_deref() == Some(REGISTRY_VALUE) { return Ok(()); } key.set_value(executable.as_ref(), ®ISTRY_VALUE)?; tracing::info!(%executable, "Set high-performance GPU preference"); Ok(()) } #[cfg(target_os = "linux")] async fn high_performance_gpu_environment() -> Vec<(String, String)> { match switcheroo_gpu_environment().await { Ok(Some(environment)) => { tracing::info!( "Using switcheroo-control environment for high-performance GPU" ); environment } Ok(None) => fallback_high_performance_gpu_environment(), Err(error) => { tracing::debug!( %error, "Failed to query switcheroo-control; using PRIME fallback" ); fallback_high_performance_gpu_environment() } } } #[cfg(target_os = "linux")] async fn switcheroo_gpu_environment() -> crate::Result>> { use std::collections::HashMap; use zbus::zvariant::OwnedValue; use zbus::{Connection, Proxy}; let connection = Connection::system().await?; let proxy = Proxy::new( &connection, "net.hadess.SwitcherooControl", "/net/hadess/SwitcherooControl", "net.hadess.SwitcherooControl", ) .await?; let gpus: Vec> = proxy.get_property("GPUs").await?; for mut gpu in gpus { let is_default = gpu .get("Default") .and_then(|value| bool::try_from(value).ok()); if is_default != Some(false) { continue; } let Some(environment) = gpu .remove("Environment") .and_then(|value| Vec::::try_from(value).ok()) else { continue; }; let mut entries = environment.chunks_exact(2); let parsed = entries .by_ref() .filter(|entry| !entry[0].is_empty()) .map(|entry| (entry[0].clone(), entry[1].clone())) .collect::>(); if entries.remainder().is_empty() && !parsed.is_empty() { return Ok(Some(parsed)); } } Ok(None) } #[cfg(target_os = "linux")] fn fallback_high_performance_gpu_environment() -> Vec<(String, String)> { [ ("DRI_PRIME", "1"), ("__NV_PRIME_RENDER_OFFLOAD", "1"), ("__VK_LAYER_NV_optimus", "NVIDIA_only"), ("__GLX_VENDOR_LIBRARY_NAME", "nvidia"), ] .into_iter() .map(|(key, value)| (key.to_string(), value.to_string())) .collect() } /// Mojang/HMCL ordered rule semantics: an empty list allows, a non-empty list /// starts disallowed, and each matching rule replaces the previous decision. #[tracing::instrument] pub fn parse_rules( rules: &[d::minecraft::Rule], java_version: &str, quick_play_type: &QuickPlayType, minecraft_updated: bool, ) -> bool { if rules.is_empty() { return true; } rules.iter().fold(false, |allowed, rule| { parse_rule(rule, java_version, quick_play_type, minecraft_updated) .unwrap_or(allowed) }) } /// Returns this rule's action only when every declared OS and feature /// restriction matches. OS and features on the same rule are conjunctive. #[tracing::instrument] pub fn parse_rule( rule: &d::minecraft::Rule, java_version: &str, quick_play_type: &QuickPlayType, minecraft_updated: bool, ) -> Option { let os_matches = rule.os.as_ref().is_none_or(|os| { crate::util::platform::os_rule(os, java_version, minecraft_updated) }); let features_match = rule.features.as_ref().is_none_or(|features| { let facts = [ (features.is_demo_user, false), (features.has_custom_resolution, true), (features.has_quick_plays_support, true), ( features.is_quick_play_singleplayer, matches!(quick_play_type, QuickPlayType::Singleplayer(_)), ), ( features.is_quick_play_multiplayer, matches!(quick_play_type, QuickPlayType::Server(..)), ), (features.is_quick_play_realms, false), ]; facts.into_iter().all(|(expected, actual)| { expected.is_none_or(|expected| expected == actual) }) }); (os_matches && features_match) .then_some(matches!(rule.action, RuleAction::Allow)) } macro_rules! processor_rules { ($dest:expr; $($name:literal : client => $client:expr, server => $server:expr;)+) => { $(std::collections::HashMap::insert( $dest, String::from($name), daedalus::modded::SidedDataEntry { client: String::from($client), server: String::from($server), }, );)+ } } fn processor_output_sha1(value: &str) -> Option<&str> { let value = value.trim().trim_matches(['\'', '"']); let value = value .strip_prefix("sha1:") .or_else(|| value.strip_prefix("SHA1:")) .unwrap_or(value); (value.len() == 40 && value.bytes().all(|byte| byte.is_ascii_hexdigit())) .then_some(value) } async fn processor_outputs_are_current( processor: &d::modded::Processor, libraries_dir: &Path, data: &std::collections::HashMap, allowed_roots: &[&Path], ) -> bool { let inferred_outputs: std::collections::HashMap; let outputs = if let Some(outputs) = processor .outputs .as_ref() .filter(|outputs| !outputs.is_empty()) { outputs } else { let is_binary_patcher = processor.jar.split(':').nth(1).is_some_and(|artifact| { artifact.eq_ignore_ascii_case("binarypatcher") }); if !is_binary_patcher { return false; } let mut inferred = std::collections::HashMap::new(); for (index, argument) in processor.args.iter().enumerate() { if argument != "--output" { continue; } let Some(path_template) = processor.args.get(index + 1) else { return false; }; let Some(key) = path_template .strip_prefix('{') .and_then(|key| key.strip_suffix('}')) else { return false; }; let hash_key = format!("{key}_SHA"); if !data.contains_key(&hash_key) { return false; } inferred.insert(path_template.clone(), format!("{{{hash_key}}}")); } if inferred.is_empty() { return false; } inferred_outputs = inferred; &inferred_outputs }; let mut canonical_roots = Vec::with_capacity(allowed_roots.len()); for root in allowed_roots { if let Ok(root) = tokio::fs::canonicalize(root).await { canonical_roots.push(root); } } if canonical_roots.is_empty() { return false; } for (path_template, hash_template) in outputs { let resolved = match args::get_processor_arguments( libraries_dir, &[path_template, hash_template], data, ) { Ok(resolved) => resolved, Err(error) => { tracing::debug!( processor = %processor.jar, %error, "Could not resolve processor output" ); return false; } }; let [resolved_path, resolved_hash] = resolved.as_slice() else { return false; }; let Some(expected_sha1) = processor_output_sha1(resolved_hash) else { return false; }; let path = PathBuf::from(resolved_path); if !path.is_absolute() { return false; } let Ok(path) = tokio::fs::canonicalize(path).await else { return false; }; if !canonical_roots.iter().any(|root| path.starts_with(root)) { return false; } let Ok((_, actual_sha1)) = crate::util::fetch::sha1_file_async(&path).await else { return false; }; if !actual_sha1.eq_ignore_ascii_case(expected_sha1) { return false; } } true } pub async fn get_java_version_from_launch_context( context: &InstanceLaunchContext, version_info: &VersionInfo, ) -> crate::Result> { if let Some(java) = context.launch_overrides.java_path.as_ref() { let java = crate::api::jre::check_jre(std::path::PathBuf::from(java)).await; if let Ok(java) = java { validate_loader_java_version(version_info, &java)?; return Ok(Some(java)); } } let key = required_java_major(version_info); let java_version = crate::api::jre::find_java_for_version(key).await?; if let Some(java_version) = &java_version { validate_loader_java_version(version_info, java_version)?; } Ok(java_version) } fn required_java_major(version_info: &VersionInfo) -> u32 { if version_uses_liteloader(version_info) { 8 } else { version_info .java_version .as_ref() .map_or(8, |java| java.major_version) } } fn version_uses_liteloader(version_info: &VersionInfo) -> bool { version_info .libraries .iter() .any(|library| is_liteloader_library(&library.name)) } fn is_liteloader_library(name: &str) -> bool { let mut coordinates = name.split(':'); coordinates.next() == Some("com.mumfrey") && coordinates.next() == Some("liteloader") && coordinates.next().is_some() } fn validate_loader_java_version( version_info: &VersionInfo, java_version: &JavaVersion, ) -> crate::Result<()> { if version_uses_liteloader(version_info) && java_version.parsed_version != 8 { return Err(crate::ErrorKind::LauncherError(format!( "LiteLoader requires Java 8, but Java {} is selected", java_version.parsed_version )) .into()); } Ok(()) } pub async fn get_loader_version_from_profile( game_version: &str, loader: ModLoader, loader_version: Option<&str>, ) -> crate::Result> { get_loader_version_from_profile_with_cache( game_version, loader, loader_version, None, ) .await } pub async fn get_loader_version_from_profile_with_cache( game_version: &str, loader: ModLoader, loader_version: Option<&str>, cache_behaviour: Option, ) -> crate::Result> { if loader == ModLoader::Vanilla { return Ok(None); } if loader == ModLoader::OptiFine { return optifine::resolve_loader_version(game_version, loader_version) .await; } let versions = crate::api::metadata::get_loader_versions_for_game_with_cache( loader.as_meta_str(), game_version, cache_behaviour, ) .await?; if let Some(loaders) = loader_versions_for_game_version(&versions, game_version) { Ok(select_loader_version(loaders, loader_version).cloned()) } else { Ok(None) } } fn select_loader_version<'a>( loaders: &'a [LoaderVersion], requested: Option<&str>, ) -> Option<&'a LoaderVersion> { let requested = requested.unwrap_or("latest"); loaders .iter() .find(|version| match requested { "latest" => true, "stable" => version.stable, id => version.id == id, }) .or_else(|| (requested == "stable").then(|| loaders.first()).flatten()) } fn explicit_loader_version(requested: Option<&str>) -> Option<&str> { requested.filter(|version| { !version.is_empty() && !matches!(*version, "latest" | "stable") }) } fn missing_loader_version_fallback( loader: ModLoader, game_version: &str, requested: Option<&str>, ) -> Result<&'static str, String> { if let Some(requested) = explicit_loader_version(requested) { Err(format!( "Loader version {requested} is not available for {} {game_version}", loader.as_str() )) } else { Ok("stable") } } fn installed_offline_loader_version( loader: ModLoader, requested: Option<&str>, ) -> Option { if !matches!( loader, ModLoader::Fabric | ModLoader::Forge | ModLoader::NeoForge | ModLoader::Quilt | ModLoader::Cleanroom | ModLoader::LiteLoader | ModLoader::LegacyFabric | ModLoader::Babric ) { return None; } let id = explicit_loader_version(requested)?; Some(LoaderVersion { id: id.to_string(), url: String::new(), stable: false, profile_source: Default::default(), fallback_url: None, }) } fn loader_versions_for_game_version<'a>( manifest: &'a Manifest, game_version: &str, ) -> Option<&'a [LoaderVersion]> { let version = manifest.game_versions.iter().find(|x| { x.id.replace(daedalus::modded::DUMMY_REPLACE_STRING, game_version) == game_version })?; if let Some(version_group) = &version.version_group { manifest .version_groups .iter() .find(|group| group.id == *version_group) .map(|group| group.loaders.as_slice()) } else { Some(version.loaders.as_slice()) } } /// Resolves the Minecraft version manifest and finds the index for the given /// game version. If the version isn't found in the cache, forces a manifest /// refresh to pick up newly-released versions. pub async fn resolve_minecraft_manifest( game_version: &str, state: &State, ) -> crate::Result<(d::minecraft::VersionManifest, usize)> { resolve_minecraft_manifest_with_cache(game_version, state, None).await } pub async fn resolve_minecraft_manifest_with_cache( game_version: &str, state: &State, cache_behaviour: Option, ) -> crate::Result<(d::minecraft::VersionManifest, usize)> { let minecraft = crate::api::metadata::get_minecraft_versions_with_cache( cache_behaviour, ) .await?; if let Some(idx) = minecraft .versions .iter() .position(|it| it.id == game_version) { return Ok((minecraft, idx)); } // Version not found in the first manifest lookup. Online launches force a // refresh for newly released versions; offline launches repeat a cache-only // lookup so they never reach the network. let refreshed = crate::state::CachedEntry::get_minecraft_manifest( if cache_behaviour == Some(CacheBehaviour::CacheOnly) { Some(CacheBehaviour::CacheOnly) } else { Some(CacheBehaviour::MustRevalidate) }, &state.pool, &state.api_semaphore, ) .await? .ok_or_else(|| { crate::ErrorKind::NoValueFor("minecraft versions".to_string()) })?; let idx = refreshed .versions .iter() .position(|it| it.id == game_version) .ok_or(crate::ErrorKind::LauncherError(format!( "Invalid game version: {game_version}" )))?; Ok((refreshed, idx)) } async fn get_instance_full_path( instance_path: &str, game_dir_override: Option<&str>, ) -> crate::Result { let state = State::get().await?; let full_path = io::canonicalize( state .directories .resolve_game_dir(instance_path, game_dir_override), )?; Ok(full_path) } /// Writes downloaded version metadata and the client jar into the external /// `.minecraft/versions/` directory. Shared artifacts remain in /// Axolotl's metadata cache, while the external root retains the conventional /// Minecraft version metadata structure. async fn materialize_external_version( instance: &Instance, version_id: &str, version_info: &VersionInfo, state: &State, ) -> crate::Result<()> { let Some((version_dir, _)) = external_version_dir_for_game_override(instance) else { return Ok(()); }; let Some(version_name) = version_dir.file_name().and_then(|name| name.to_str()) else { return Ok(()); }; io::create_dir_all(&version_dir).await?; let mut serialized = serde_json::to_value(version_info)?; if let Some(object) = serialized.as_object_mut() { object.insert("id".to_string(), version_name.to_string().into()); } let version_json = version_dir.join(format!("{version_name}.json")); io::write(&version_json, serde_json::to_vec(&serialized)?).await?; let source_jar = state .directories .version_dir(version_id) .join(format!("{version_id}.jar")); let target_jar = version_dir.join(format!("{version_name}.jar")); tokio::fs::copy(source_jar, target_jar).await?; Ok(()) } async fn promote_external_instance_link( instance: &Instance, state: &State, ) -> crate::Result<()> { if instance.is_direct_linked() { return Ok(()); } let Some(direct) = DirectLinkedLaunch::from_game_dir_override(instance)? else { return Ok(()); }; let game_dir_mode = direct.game_dir_mode.ok_or_else(|| { crate::ErrorKind::LauncherError( "External instance link has no game-directory mode".to_string(), ) })?; let root = direct.dot_minecraft.to_string_lossy().to_string(); let version_json = direct .version_json .as_deref() .map(io::canonicalize) .transpose()? .map(|path| path.to_string_lossy().to_string()); let mut tx = state.pool.begin().await?; crate::state::instances::adapters::sqlite::instance_rows::set_direct_link_fields( &instance.id, &crate::state::instances::adapters::sqlite::instance_rows::DirectLinkFields { launcher: Some("generic".to_string()), launcher_root: Some(root.clone()), dot_minecraft: Some(root.clone()), version_id: Some(direct.version_id.clone()), version_json_path: version_json, game_dir_mode: Some(game_dir_mode.key().to_string()), }, &mut tx, ) .await?; if let Some(group) = direct_link_group(&direct.dot_minecraft) { crate::state::instances::adapters::sqlite::instance_rows::replace_instance_groups( &instance.id, &[group], &mut tx, ) .await?; } tx.commit().await?; emit_instance(&instance.id, InstancePayloadType::Edited).await?; Ok(()) } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum InstanceCompletionPolicy { FinalizeHere, DeferToInstallJob, } pub(crate) async fn run_instance_install_command( instance_id: String, cancellation: CancellationToken, mut command: Command, ) -> crate::Result { let state = State::get().await?; let instance_lock = state.lock_instance_content_exclusive(&instance_id).await; let task = tokio::spawn(async move { let _instance_lock = instance_lock; if cancellation.is_cancelled() { return Err(crate::ErrorKind::OtherError( "Install was canceled".to_string(), ) .into()); } command .kill_on_drop(true) .stdout(Stdio::piped()) .stderr(Stdio::piped()); let mut child = command.spawn()?; let stdout = child.stdout.take(); let stderr = child.stderr.take(); let stdout_task = tokio::spawn(async move { let mut output = Vec::new(); if let Some(mut stdout) = stdout { stdout.read_to_end(&mut output).await?; } Ok::<_, std::io::Error>(output) }); let stderr_task = tokio::spawn(async move { let mut output = Vec::new(); if let Some(mut stderr) = stderr { stderr.read_to_end(&mut output).await?; } Ok::<_, std::io::Error>(output) }); let (status, canceled) = tokio::select! { biased; _ = cancellation.cancelled() => { let _ = child.start_kill(); (child.wait().await?, true) } status = child.wait() => (status?, false), }; let stdout = stdout_task.await.map_err(std::io::Error::other)??; let stderr = stderr_task.await.map_err(std::io::Error::other)??; if canceled { return Err(crate::ErrorKind::OtherError( "Install was canceled".to_string(), ) .into()); } Ok(Output { status, stdout, stderr, }) }); task.await? } pub async fn install_minecraft_with_reporter( context: &InstanceLaunchContext, repairing: bool, reporter: Option, completion_policy: InstanceCompletionPolicy, ) -> crate::Result<()> { install_minecraft_with_local_source( context, repairing, reporter, None, completion_policy, ) .await } async fn install_minecraft_with_local_source( context: &InstanceLaunchContext, repairing: bool, reporter: Option, local_source: Option<&LocalRuntimeSource>, completion_policy: InstanceCompletionPolicy, ) -> crate::Result<()> { let instance = &context.instance; let content_set = &context.applied_content_set; let phase_details = InstallPhaseDetails::Minecraft { game_version: content_set.game_version.clone(), loader: content_set.loader, }; let loading_bar = if reporter.is_none() { Some( init_loading( LoadingBarType::MinecraftDownload { // If we are downloading minecraft for a profile, provide its name and uuid instance_name: instance.name.clone(), instance_id: instance.id.clone(), }, 100.0, "Downloading Minecraft", ) .await?, ) } else { None }; let state = State::get().await?; crate::state::instances::commands::set_instance_install_stage( &instance.id, InstanceInstallStage::MinecraftInstalling, &state.pool, ) .await?; emit_instance(&instance.id, InstancePayloadType::Edited).await?; let instance_path = get_instance_full_path( &instance.path, instance.game_dir_override.as_deref(), ) .await?; if let Some(reporter) = &reporter { reporter .update( InstallPhaseId::ResolvingMinecraft, None, phase_details.clone(), ) .await?; } let (minecraft, version_index) = resolve_minecraft_manifest(&content_set.game_version, &state).await?; let version = &minecraft.versions[version_index]; let minecraft_updated = version_index <= minecraft .versions .iter() .position(|x| x.id == "22w16a") .unwrap_or(0); if content_set.loader != ModLoader::Vanilla && let Some(reporter) = &reporter { reporter .update( InstallPhaseId::ResolvingLoader, None, phase_details.clone(), ) .await?; } let mut loader_version = get_loader_version_from_profile( &content_set.game_version, content_set.loader, content_set.loader_version.as_deref(), ) .await?; // If no loader version is selected, try to select the stable version! if content_set.loader != ModLoader::Vanilla && loader_version.is_none() { let fallback = missing_loader_version_fallback( content_set.loader, &content_set.game_version, content_set.loader_version.as_deref(), ) .map_err(crate::ErrorKind::LauncherError)?; loader_version = get_loader_version_from_profile( &content_set.game_version, content_set.loader, Some(fallback), ) .await?; crate::state::instances::commands::set_applied_content_set_loader_version( &instance.id, loader_version.as_ref().map(|x| x.id.as_str()), &state.pool, ) .await?; } let version_jar = loader_version.as_ref().map_or(version.id.clone(), |it| { format!("{}-{}", version.id.clone(), it.id.clone()) }); // Download version info (5) let mut version_info = download::download_version_info( &state, version, content_set.loader, loader_version.as_ref(), Some(repairing), loading_bar.as_ref(), reporter.as_ref(), ) .await?; let key = required_java_major(&version_info); if let Some(reporter) = &reporter { reporter .update( InstallPhaseId::PreparingJava, Some(InstallProgress { current: 0, total: 4, secondary: None, }), InstallPhaseDetails::Java { major_version: key, step: InstallJavaStep::Resolving, }, ) .await?; } let java_installation = if let Some(java_version) = get_java_version_from_launch_context(context, &version_info).await? { Some((std::path::PathBuf::from(java_version.path), false)) } else if let Some(discovered) = crate::api::jre::find_java_for_version(key) .await .unwrap_or_default() { tracing::info!( "Reusing discovered Java {} at {}", discovered.version, discovered.path ); Some((std::path::PathBuf::from(discovered.path), true)) } else { if let Some(reporter) = &reporter { crate::api::jre::auto_install_java_with_reporter( key, reporter.clone(), ) .await? } else { crate::api::jre::auto_install_java_with_loading(key, true).await? } .map(|path| (path, true)) }; let java_version = if let Some((java_path, set_java)) = java_installation { if let Some(reporter) = &reporter { reporter .update( InstallPhaseId::PreparingJava, Some(InstallProgress { current: 4, total: 4, secondary: None, }), InstallPhaseDetails::Java { major_version: key, step: InstallJavaStep::Validating, }, ) .await?; } let java_version = crate::api::jre::check_jre(java_path).await?; validate_loader_java_version(&version_info, &java_version)?; if set_java { java_version.upsert(&state.pool).await?; } Some(java_version) } else { None }; // Download minecraft (5-90) if let Some(reporter) = &reporter { reporter .update( InstallPhaseId::DownloadingMinecraft, None, phase_details.clone(), ) .await?; } download::download_minecraft( &state, local_source, &content_set.game_version, &version_info, loading_bar.as_ref(), java_version .as_ref() .map(|java| java.architecture.as_str()) .unwrap_or(std::env::consts::ARCH), repairing, minecraft_updated, reporter.clone(), phase_details.clone(), ) .await?; materialize_external_version(instance, &version_jar, &version_info, &state) .await?; // Version-isolated external instances are direct-managed from creation. // Complete their external assets/libraries now so the first launch never // falls back to Axolotl's shared runtime directories. let runtime_adapter = InstanceRuntimeAdapter::for_instance(instance, &state.directories)?; if let Some(direct) = runtime_adapter.direct_link() { let resolved = direct.resolve()?; direct_ensure::ensure_direct_launch_dependencies( &state, &direct, &resolved.merged.libraries, &version_info, java_version .as_ref() .map(|java| java.architecture.as_str()) .unwrap_or(std::env::consts::ARCH), minecraft_updated, ) .await?; } let client_path = state .directories .version_dir(&version_jar) .join(format!("{version_jar}.jar")); let Some(java_version) = java_version else { let protocol_version = read_protocol_version_from_jar(client_path).await?; crate::state::instances::commands::set_applied_content_set_protocol_version( &instance.id, protocol_version, &state.pool, ) .await?; promote_external_instance_link(instance, &state).await?; if completion_policy == InstanceCompletionPolicy::FinalizeHere { crate::state::instances::commands::set_instance_install_stage( &instance.id, InstanceInstallStage::Installed, &state.pool, ) .await?; emit_instance(&instance.id, InstancePayloadType::Edited).await?; } if let Some(loading_bar) = &loading_bar { emit_loading( loading_bar, 1.0, Some("Finished downloading Minecraft resources"), )?; } tracing::info!( java_version = key, instance_id = instance.id, "Postponed Java setup after downloading Minecraft resources" ); return Ok(()); }; if content_set.loader == ModLoader::OptiFine && let Some(loader_version) = &loader_version { if let Some(reporter) = &reporter { reporter .update( InstallPhaseId::RunningLoaderProcessors, None, phase_details.clone(), ) .await?; } optifine::install_optifine_libraries( &state, std::path::Path::new(&java_version.path), &content_set.game_version, &loader_version.id, &client_path, ) .await?; } if let Some(processors) = &version_info.processors { let libraries_dir = state.directories.libraries_dir(); if let Some(ref mut data) = version_info.data { processor_rules! { data; "SIDE": client => "client", server => ""; "MINECRAFT_JAR" : client => client_path.to_string_lossy(), server => ""; "MINECRAFT_VERSION": client => content_set.game_version.clone(), server => ""; "ROOT": client => instance_path.to_string_lossy(), server => ""; "LIBRARY_DIR": client => libraries_dir.to_string_lossy(), server => ""; } if let Some(loading_bar) = &loading_bar { emit_loading( loading_bar, 0.0, Some("Running forge processors"), )?; } let total_length = processors.len(); if let Some(reporter) = &reporter { reporter .update( InstallPhaseId::RunningLoaderProcessors, Some(InstallProgress { current: 0, total: total_length as u64, secondary: None, }), phase_details.clone(), ) .await?; } // Forge processors (90-100) for (index, processor) in processors.iter().enumerate() { let processor_started = Instant::now(); if let Some(sides) = &processor.sides && !sides.contains(&String::from("client")) { if let Some(reporter) = &reporter { reporter .update( InstallPhaseId::RunningLoaderProcessors, Some(InstallProgress { current: (index + 1) as u64, total: total_length as u64, secondary: None, }), phase_details.clone(), ) .await?; } continue; } if processor_outputs_are_current( processor, &libraries_dir, data, &[&instance_path, &libraries_dir], ) .await { tracing::info!( processor = %processor.jar, index, duration_ms = processor_started.elapsed().as_millis(), "Skipped Forge processor because all declared outputs are valid" ); if let Some(loading_bar) = &loading_bar { emit_loading( loading_bar, 30.0 / total_length as f64, Some(&format!( "Running forge processor {index}/{total_length}" )), )?; } if let Some(reporter) = &reporter { reporter .update( InstallPhaseId::RunningLoaderProcessors, Some(InstallProgress { current: (index + 1) as u64, total: total_length as u64, secondary: None, }), phase_details.clone(), ) .await?; } continue; } let cp = { let mut cp = processor.classpath.clone(); cp.push(processor.jar.clone()); cp }; let mut command = Command::new(&java_version.path); command .arg("-cp") .arg(args::get_class_paths_jar( &libraries_dir, &cp, &java_version.architecture, )?) .arg( args::get_processor_main_class(args::get_lib_path( &libraries_dir, &processor.jar, false, )?) .await? .ok_or_else(|| { crate::ErrorKind::LauncherError(format!( "Could not find processor main class for {}", processor.jar )) })?, ) .args(args::get_processor_arguments( &libraries_dir, &processor.args, data, )?); let child = run_instance_install_command( instance.id.clone(), reporter .as_ref() .map(InstallProgressReporter::cancellation_token) .unwrap_or_default(), command, ) .await .map_err(|err| { crate::ErrorKind::LauncherError(format!( "Error running processor: {err}", )) })?; if !child.status.success() { return Err(crate::ErrorKind::LauncherError(format!( "Processor error: {}", String::from_utf8_lossy(&child.stderr) )) .as_error()); } tracing::info!( processor = %processor.jar, index, duration_ms = processor_started.elapsed().as_millis(), "Completed Forge processor" ); if let Some(loading_bar) = &loading_bar { emit_loading( loading_bar, 30.0 / total_length as f64, Some(&format!( "Running forge processor {index}/{total_length}" )), )?; } if let Some(reporter) = &reporter { reporter .update( InstallPhaseId::RunningLoaderProcessors, Some(InstallProgress { current: (index + 1) as u64, total: total_length as u64, secondary: None, }), phase_details.clone(), ) .await?; } } } } let protocol_version = read_protocol_version_from_jar(client_path).await?; crate::state::instances::commands::set_applied_content_set_protocol_version( &instance.id, protocol_version, &state.pool, ) .await?; promote_external_instance_link(instance, &state).await?; if completion_policy == InstanceCompletionPolicy::FinalizeHere { crate::state::instances::commands::set_instance_install_stage( &instance.id, InstanceInstallStage::Installed, &state.pool, ) .await?; emit_instance(&instance.id, InstancePayloadType::Edited).await?; } if let Some(loading_bar) = &loading_bar { emit_loading(loading_bar, 1.0, Some("Finished installing"))?; } Ok(()) } pub async fn install_minecraft_for_instance_id_with_reporter( instance_id: &str, repairing: bool, reporter: Option, completion_policy: InstanceCompletionPolicy, ) -> crate::Result<()> { let state = State::get().await?; let context = crate::state::instances::commands::get_instance_launch_context( instance_id, &state.pool, ) .await? .ok_or_else(|| { crate::ErrorKind::OtherError(format!( "Tried to install a nonexistent or unloaded instance {instance_id}!" )) })?; install_minecraft_with_reporter( &context, repairing, reporter, completion_policy, ) .await } pub async fn install_minecraft_for_instance_id_with_local_source( instance_id: &str, local_source: Option, repairing: bool, reporter: Option, completion_policy: InstanceCompletionPolicy, ) -> crate::Result<()> { let state = State::get().await?; let context = crate::state::instances::commands::get_instance_launch_context( instance_id, &state.pool, ) .await? .ok_or_else(|| { crate::ErrorKind::OtherError(format!( "Tried to install a nonexistent or unloaded instance {instance_id}!" )) })?; install_minecraft_with_local_source( &context, repairing, reporter, local_source.as_ref(), completion_policy, ) .await } pub async fn read_protocol_version_from_jar( path: PathBuf, ) -> crate::Result> { let zip = async_zip::tokio::read::fs::ZipFileReader::new(path).await?; let Some(entry_index) = zip .file() .entries() .iter() .position(|x| matches!(x.filename().as_str(), Ok("version.json"))) else { return Ok(None); }; #[derive(Deserialize, Debug)] struct VersionData { protocol_version: Option, } let mut data = vec![]; zip.reader_with_entry(entry_index) .await? .read_to_end_checked(&mut data) .await?; let data: VersionData = serde_json::from_slice(&data)?; Ok(data.protocol_version) } /// Selects a Java runtime from the HMCL private settings of a directly /// associated instance, if one is configured and usable. async fn select_hmcl_java( direct: &DirectLinkedLaunch, settings: &crate::launcher::local_version::HmclVersionSettings, ) -> Option { select_linked_java(hmcl_java_candidates(settings, &direct.dot_minecraft)) .await } /// Selects a Java runtime from the PCL/PCL-CE per-instance Java preference of /// a directly associated instance (see /// `PclLaunchSettings::java_candidates`). async fn select_pcl_java( direct: &DirectLinkedLaunch, settings: &direct_link::PclLaunchSettings, ) -> Option { select_linked_java(settings.java_candidates( &direct.version_dir(), direct.launcher_root.as_deref(), )) .await } /// Verifies linked-launcher Java candidates in order and returns the first /// usable one. async fn select_linked_java(candidates: Vec) -> Option { for candidate in candidates { match crate::api::jre::check_jre(candidate).await { Ok(java) => { tracing::info!( java = %java.path, "Using the Java runtime configured by the linked launcher" ); return Some(java); } Err(error) => { tracing::debug!( %error, "Skipping unusable linked-launcher Java candidate" ); } } } None } /// Resolves the game directory a directly associated instance actually plays /// from. The selected runtime adapter preserves each external launcher's /// shared or version-isolated path semantics. `None` when the instance is not /// directly associated or its linked metadata is incomplete. /// /// Content browsing (mods/worlds listing) uses this so it reads the same /// folders the launch does. If a version chain cannot be resolved, the /// selected runtime adapter supplies a mode-appropriate external fallback. pub(crate) fn linked_game_dir(instance: &Instance) -> Option { let adapter = InstanceRuntimeAdapter::external_for_instance(instance).ok()??; Some(adapter.game_dir()) } fn link_project_and_version( link: &InstanceLink, ) -> (Option<&String>, Option<&String>) { match link { InstanceLink::ModrinthModpack { project_id, version_id, } | InstanceLink::CurseForgeModpack { project_id, version_id, } => (Some(project_id), Some(version_id)), InstanceLink::ServerProject { project_id } => (Some(project_id), None), InstanceLink::ServerProjectModpack { server_project_id, content_version_id, .. } => (Some(server_project_id), Some(content_version_id)), InstanceLink::ImportedModpack { project_id, version_id, .. } => (project_id.as_ref(), version_id.as_ref()), InstanceLink::Unmanaged | InstanceLink::SharedInstance { .. } => { (None, None) } } } #[tracing::instrument(skip_all)] #[allow(clippy::too_many_arguments)] pub async fn launch_minecraft( java_args: &[String], env_args: &[(String, String)], mc_set_options: &[(String, String)], wrapper: &Option, memory: &MemorySettings, resolution: &WindowSize, maximize_window: bool, credentials: &Credentials, post_exit_hook: Option, context: &InstanceLaunchContext, gc_intent: Option, gc_report: &mut Option, mut quick_play_type: QuickPlayType, offline_mode: bool, ) -> crate::Result { let instance = &context.instance; let content_set = &context.applied_content_set; let mut __lt = std::time::Instant::now(); let mut combined_java_args = crate::pack::hosted::java_arguments(&instance.id).await?; tracing::info!("[launch-timing] hosted_java_arguments: {}ms", __lt.elapsed().as_millis()); __lt = std::time::Instant::now(); combined_java_args.extend_from_slice(java_args); let java_args = combined_java_args.as_slice(); if instance.install_stage == InstanceInstallStage::PackInstalling || instance.install_stage == InstanceInstallStage::MinecraftInstalling { return Err(crate::ErrorKind::LauncherError( "Instance is still installing".to_string(), ) .into()); } if instance.install_stage != InstanceInstallStage::Installed { return Err(crate::ErrorKind::LauncherError( "Instance is not installed; start an install job first".to_string(), ) .into()); } let state = State::get().await?; let runtime = InstanceRuntimeAdapter::for_instance(instance, &state.directories)?; let direct_launch = runtime.direct_link().cloned(); if direct_launch.is_some() && !instance.is_direct_linked() { // Instances created before direct-link metadata was introduced retain // their external `game_dir_override`. Their adapter above already // launches them in place; persist the resolved identity now so future // launches, content scans, and settings all use the same external // runtime. A persistence failure must not make an otherwise valid // existing instance unlaunchable. if let Err(error) = promote_external_instance_link(instance, &state).await { tracing::warn!( %error, instance = %instance.id, "Could not persist the migrated external instance link" ); } } let mut resolved_linked = direct_launch .as_ref() .map(DirectLinkedLaunch::resolve) .transpose()?; // PCL/PCL-CE may isolate a version under versions/; HMCL and generic // linked installations launch from the shared .minecraft root. let instance_path = if let Some(resolved) = &resolved_linked { io::canonicalize(&resolved.game_dir)? } else { get_instance_full_path( &instance.path, instance.game_dir_override.as_deref(), ) .await? }; let offline_skin_pack = if direct_launch.is_some() { // Writing the offline skin pack would modify the linked folder. crate::minecraft_skins::OfflineSkinPackOptions { enabled_pack_id: None, } } else { crate::minecraft_skins::prepare_offline_skin_resource_pack( credentials, &instance_path, &content_set.game_version, ) .await? }; let cache_behaviour = offline_mode.then_some(CacheBehaviour::CacheOnly); let mut linked_libraries = None; // PCL/PCL-CE expose per-instance launch preferences (Java, memory, custom // arguments) in their own config files; load them once for this launch. let pcl_launch = direct_launch .as_ref() .filter(|direct| { matches!( direct.dialect, LinkedLauncherDialect::Pcl | LinkedLauncherDialect::PclCe ) }) .map(DirectLinkedLaunch::pcl_launch_settings); let ( version_info, minecraft_updated, version_jar, launch_version_id, launch_version_type, quick_play_version, hmcl_settings, launch_release_time, ) = if let Some(direct) = &direct_launch { // The dialect-specific resolver has already folded the linked manifest; // no version metadata is downloaded and no repair pass runs. let mut merged = resolved_linked .take() .expect("direct launch resolution accompanies direct metadata") .merged; // Same loader normalization the managed install path applies. The // merged list drives the ensure pass, native extraction, the launch // classpath, and the VersionInfo projection below, so dropping the // Cleanroom conflicts here — the whole vanilla LWJGL 2 family // (`org.lwjgl.lwjgl:lwjgl` binding, `lwjgl_util` at // 2.9.4-nightly-20150209, and the natives-only `lwjgl-platform` // carrier whose root-level `lwjgl.dll` would otherwise be what // `System.loadLibrary("lwjgl")` resolves first), plus the vanilla JNA // platform and Mojang ICU (pre-existing Axolotl Cleanroom rule) — // covers direct ensure, classpath, and launch at once; the Cleanroom // LWJGL 3 line, lwjglxx, and the vanilla JNA core are never affected. for removed_library in normalize_merged_loader_libraries(content_set.loader, &mut merged) { tracing::info!( loader = content_set.loader.as_str(), version = %merged.id, removed_library, "Removed loader-incompatible library from linked version profile" ); } linked_libraries = Some(merged.libraries.clone()); // Best-effort reuse of the shared metadata manifest for behaviors // keyed on the vanilla release order (Quick Play support, pre-1.13 // library rules); unknown base versions fall back conservatively. let (minecraft_updated, quick_play_version) = match resolve_minecraft_manifest_with_cache( &content_set.game_version, &state, cache_behaviour, ) .await { Ok((minecraft, version_index)) => ( version_index <= minecraft .versions .iter() .position(|x| x.id == "22w16a") .unwrap_or(0), QuickPlayVersion::find_version( version_index, &minecraft.versions, ), ), Err(error) => { tracing::warn!( %error, version = %content_set.game_version, "Linked version not found in Minecraft metadata; \ using conservative launch defaults" ); conservative_launch_facts(&merged) } }; let version_jar = merged .jar .clone() .unwrap_or_else(|| content_set.game_version.clone()); let hmcl_settings = std::mem::take(&mut merged.hmcl_settings); let version_info = merged_to_version_info(merged)?; let launch_version_type = version_info.type_.clone(); ( version_info, minecraft_updated, version_jar, direct.version_id.clone(), launch_version_type, quick_play_version, hmcl_settings, // Unused: the options.txt language pass never runs for directly // associated instances. chrono::DateTime::::MIN_UTC, ) } else { let (minecraft, version_index) = resolve_minecraft_manifest_with_cache( &content_set.game_version, &state, cache_behaviour, ) .await?; let version = &minecraft.versions[version_index]; let minecraft_updated = version_index <= minecraft .versions .iter() .position(|x| x.id == "22w16a") .unwrap_or(0); let loader_version = if offline_mode { if let Some(loader_version) = installed_offline_loader_version( content_set.loader, content_set.loader_version.as_deref(), ) { Some(loader_version) } else { get_loader_version_from_profile_with_cache( &content_set.game_version, content_set.loader, content_set.loader_version.as_deref(), cache_behaviour, ) .await? } } else { get_loader_version_from_profile_with_cache( &content_set.game_version, content_set.loader, content_set.loader_version.as_deref(), cache_behaviour, ) .await? }; if content_set.loader != ModLoader::Vanilla && loader_version.is_none() { return Err(crate::ErrorKind::LauncherError(format!( "No loader version selected for {}", content_set.loader.as_str() )) .into()); } let version_jar = loader_version.as_ref().map_or(version.id.clone(), |it| { format!("{}-{}", version.id.clone(), it.id.clone()) }); let mut version_info = if offline_mode { download::load_local_version_info( &state, version, content_set.loader, loader_version.as_ref(), ) .await? } else { download::download_version_info( &state, version, content_set.loader, loader_version.as_ref(), None, None, None, ) .await? }; if version_info.logging.is_none() { let requires_logging_info = version_index <= minecraft .versions .iter() .position(|x| x.id == "13w39a") .unwrap_or(0); if requires_logging_info && !offline_mode { version_info = download::download_version_info( &state, version, content_set.loader, loader_version.as_ref(), Some(true), None, None, ) .await?; } } let quick_play_version = QuickPlayVersion::find_version(version_index, &minecraft.versions); let launch_version_id = version.id.clone(); let launch_release_time = version.release_time; ( version_info, minecraft_updated, version_jar, launch_version_id, version.type_.clone(), quick_play_version, crate::launcher::local_version::HmclVersionSettings::default(), launch_release_time, ) }; if direct_launch.is_none() { if offline_mode { download::ensure_local_log_config(&state, &version_info)?; } else { let _ = download_log_config( &state, None, &version_info, None, false, None, ) .await?; } } tracing::info!("[launch-timing] resolve_version_info: {}ms", __lt.elapsed().as_millis()); __lt = std::time::Instant::now(); let java_version = if let Some(java) = context.launch_overrides.java_path.as_ref() { crate::api::jre::check_jre(std::path::PathBuf::from(java)).await? } else if direct_launch .as_ref() .is_some_and(|direct| direct.dialect == LinkedLauncherDialect::Hmcl) && let Some(java) = select_hmcl_java( direct_launch.as_ref().expect("checked above"), &hmcl_settings, ) .await { // The linked launcher configured its own Java runtime for this // version; prefer it over Axolotl's discovery. java } else if let (Some(direct), Some(pcl)) = (direct_launch.as_ref(), pcl_launch.as_ref()) && matches!( direct.dialect, LinkedLauncherDialect::Pcl | LinkedLauncherDialect::PclCe ) && let Some(java) = select_pcl_java(direct, pcl).await { // The linked PCL/PCL-CE instance selected its own Java runtime; // prefer it over Axolotl's discovery. java } else { let key = required_java_major(&version_info); if let Some(java) = crate::api::jre::find_java_for_version(key).await? { java } else if let Some(java) = crate::api::jre::find_compatible_java_for_version(key).await? { tracing::info!( version = java.version, java = java.path, "Using a compatible Java runtime instead of the recommended version" ); java } else { return Err(crate::ErrorKind::LauncherError( "Missing correct java installation".to_string(), ) .into()); } }; // Test jre version let java_version = crate::api::jre::check_jre(java_version.path.clone().into()).await?; validate_loader_java_version(&version_info, &java_version)?; tracing::info!("[launch-timing] resolve_java: {}ms", __lt.elapsed().as_millis()); __lt = std::time::Instant::now(); // Runtime-verify and fall back for GC arguments against the *actual* JVM // that will run Minecraft. The frontend supplies an ordered candidate // chain; we keep the preferred strategy only if this JVM understands it, // pruning unsupported tuning flags and falling back down the chain as // needed. A `None` keeps the args passed in untouched. let mut resolved_java_args = java_args.to_vec(); // Custom JVM arguments from the linked launcher enter the same GC // compatibility pipeline below: unsupported tuning flags are pruned // against the actual JVM instead of reaching it unvalidated. if let Some(pcl) = &pcl_launch { resolved_java_args.extend(pcl.extra_jvm_args()); } if let Some(gc_intent) = gc_intent { tracing::info!( java = %java_version.path, "Verifying GC arguments against the selected JVM", ); let report = crate::launcher::jvm_args::resolve_gc_block( Path::new(java_version.path.as_str()), &mut resolved_java_args, &gc_intent, ) .await; if report.fell_back() { tracing::info!(?report, "GC arguments adjusted for this JVM"); } *gc_report = Some(report); } tracing::info!("[launch-timing] resolve_gc: {}ms", __lt.elapsed().as_millis()); __lt = std::time::Instant::now(); // Apply the mappable linked-launcher private settings for directly // associated instances on top of the resolved launch configuration. let mut effective_memory = *memory; let mut effective_resolution = *resolution; // Extra game arguments appended after the vanilla game arguments. let mut extra_game_args = crate::pack::hosted::game_arguments(&instance.id).await?; if let Some(direct) = direct_launch.as_ref() && direct.dialect == LinkedLauncherDialect::Hmcl { let hmcl_effective = hmcl_with_global_fallback( &hmcl_settings, direct.launcher_root.as_deref(), ); extra_game_args.extend(apply_hmcl_settings( &hmcl_effective, &mut effective_memory, &mut effective_resolution, &mut resolved_java_args, Some(java_version.parsed_version), )); } else if let (Some(direct), Some(pcl)) = (direct_launch.as_ref(), pcl_launch.as_ref()) && matches!( direct.dialect, LinkedLauncherDialect::Pcl | LinkedLauncherDialect::PclCe ) { // PCL's RAM estimate needs the mod presence of the resolved game // directory and the bitness of the JVM chosen above. let (modable, opti_fine) = pcl_ram_profile(version_info.libraries.as_slice()); let is_32_bit_java = crate::launcher::direct_link::linked_architecture_width( &java_version.architecture, ) == "32"; if let Some(maximum) = pcl.resolve_ram_mb( &instance_path, modable, opti_fine, pcl_available_memory_gb(), is_32_bit_java, ) { tracing::info!( maximum, "Using the memory allocation configured by the linked PCL \ instance" ); effective_memory.maximum = maximum; } extra_game_args.extend(pcl.extra_game_args()); } let settings = crate::state::Settings::get(&state.pool).await?; #[cfg(target_os = "windows")] if settings.auto_set_java_high_performance_mode { if let Err(error) = set_high_performance_gpu_preference(&java_version.path) { tracing::warn!(%error, java = %java_version.path, "Failed to set Java high-performance GPU preference"); } match std::env::current_exe() { Ok(launcher_path) => { if let Err(error) = set_high_performance_gpu_preference(&launcher_path) { tracing::warn!(%error, launcher = %launcher_path.display(), "Failed to set launcher high-performance GPU preference"); } } Err(error) => { tracing::warn!(%error, "Failed to determine launcher executable for high-performance GPU preference"); } } } #[cfg(target_os = "linux")] let high_performance_gpu_environment = if settings.auto_set_java_high_performance_mode { high_performance_gpu_environment().await } else { Vec::new() }; let client_path = if let Some(direct) = &direct_launch { // The game jar lives inside the linked installation. direct.client_jar(&version_jar) } else { let vanilla_client_path = state .directories .version_dir(&version_jar) .join(format!("{version_jar}.jar")); match crate::api::instance::assemble_for_launch( &instance_path, &content_set.game_version, &vanilla_client_path, ) .await? { Some(assembled) => assembled, None => vanilla_client_path, } }; tracing::info!("[launch-timing] assemble_client: {}ms", __lt.elapsed().as_millis()); __lt = std::time::Instant::now(); let args = version_info.arguments.clone().unwrap_or_default(); let mut command = match wrapper { Some(hook) => { let mut cmd = shlex::split(hook) .ok_or_else(|| { crate::ErrorKind::LauncherError(format!( "Invalid wrapper command: {hook}", )) })? .into_iter(); let mut command = Command::new(cmd.next().ok_or( crate::ErrorKind::LauncherError( "Empty wrapper command".to_owned(), ), )?); command.args(cmd); command.arg(&java_version.path); command } None => Command::new(&java_version.path), }; let env_args = Vec::from(env_args); // Check if instance has a running process, and reject running the command if it does // Done late so a quick double call doesn't launch two instances let existing_processes = process::get_by_instance_id(&instance.id).await?; if let Some(process) = existing_processes.first() { return Err(crate::ErrorKind::LauncherError(format!( "Instance {} is already running as process {}", instance.id, process.uuid )) .as_error()); } // Ensure phase (HMCL/PCL parity): before anything reads from the linked // installation, complete its missing libraries, assets, and logging // config in the standard shared locations. Version JSONs, launcher // private configuration, and game data are never written. Offline mode // forbids network fetches, so it keeps the previous strict behavior. if !offline_mode && let (Some(direct), Some(libraries)) = (&direct_launch, linked_libraries.as_deref()) { let __t_ensure = std::time::Instant::now(); direct_ensure::ensure_direct_launch_dependencies( &state, direct, libraries, &version_info, &java_version.architecture, minecraft_updated, ) .await?; tracing::info!("[launch-timing] ensure_deps(libs+assets+log): {}ms", __t_ensure.elapsed().as_millis()); } let natives_dir = if let Some(direct) = &direct_launch { state .directories .version_natives_dir(&direct.natives_cache_key()) } else { state.directories.version_natives_dir(&version_jar) }; // Linked native archives can change outside Axolotl, so rebuild only the // Axolotl-owned linked cache on every launch. Never mutate linked folders. let __t_rm = std::time::Instant::now(); if direct_launch.is_some() && natives_dir.exists() { io::remove_dir_all(&natives_dir).await?; } tracing::info!("[launch-timing] remove_old_natives: {}ms", __t_rm.elapsed().as_millis()); if !natives_dir.exists() { io::create_dir_all(&natives_dir).await?; } if let (Some(direct), Some(libraries)) = (direct_launch.clone(), linked_libraries.clone()) { // Linked instances rebuild the Axolotl-owned natives cache on every // launch; the managed restore path below must never touch them. let target = natives_dir.clone(); let java_arch = java_version.architecture.clone(); let __t_extract = std::time::Instant::now(); tokio::task::spawn_blocking(move || { extract_linked_natives( &direct, &libraries, &target, &java_arch, minecraft_updated, ) }) .await??; tracing::info!("[launch-timing] extract_linked_natives: {}ms", __t_extract.elapsed().as_millis()); } else if direct_launch.is_none() { if offline_mode { natives::prepare_native_libraries( &state.directories.natives_dir(), &state.directories.libraries_dir(), &state.directories.caches_dir(), version_info.libraries.as_slice(), &version_jar, &java_version.architecture, minecraft_updated, ) .await?; } else { download::download_libraries( &state, None, version_info.libraries.as_slice(), &version_jar, None, 0.0, &java_version.architecture, false, minecraft_updated, None, ) .await?; } } tracing::info!("[launch-timing] ensure_libraries_and_natives: {}ms", __lt.elapsed().as_millis()); __lt = std::time::Instant::now(); tracing::debug!( "Found QuickPlayVersion for {}: {quick_play_version:?}", content_set.game_version ); if let QuickPlayType::Server(address) = &mut quick_play_type && quick_play_version.server >= QuickPlayServerVersion::BuiltinLegacy { // Record last-played for the original server address immediately so // recent-worlds can match without DNS/SRV resolution. let original = match address { ServerAddress::Unresolved(address) => parse_server_address(address) .ok() .map(|(h, p)| (h.to_owned(), p)), ServerAddress::Resolved { original_host, original_port, .. } => Some((original_host.clone(), *original_port)), }; if let Some((host, port)) = original && let Err(e) = (JoinLogEntry { instance_id: instance.id.clone(), host, port, join_time: Utc::now(), }) .upsert(&state.pool) .await { tracing::warn!("Failed to write server join log entry: {e}"); } address.resolve().await?; } let (main_class_keep_alive, main_class_path) = get_resource_file!(env "JAVA_JARS_DIR" / "theseus.jar")?; let yggdrasil_agent = if credentials.is_yggdrasil() { let account = credentials.yggdrasil.as_ref().ok_or_else(|| { crate::ErrorKind::LauncherError( "Yggdrasil account metadata is missing".to_string(), ) .as_error() })?; let metadata = crate::state::fetch_yggdrasil_metadata(&account.api_root).await?; let agent_path = main_class_keep_alive.path().join("authlib-injector.jar"); io::write( &agent_path, include_bytes!(concat!( env!("JAVA_JARS_DIR"), "/authlib-injector.jar" )), ) .await?; Some(( io::canonicalize(agent_path)?, metadata.api_root, BASE64_STANDARD.encode(metadata.raw), )) } else { None }; let rpc_server = RpcServerBuilder::new().launch().await?; let launch_libraries_dir = runtime.libraries_dir(&state.directories); let launch_log_configs_dir = runtime.log_configs_dir(&state.directories); let class_paths = if let (Some(direct), Some(libraries)) = (&direct_launch, linked_libraries.as_deref()) { args::get_linked_class_paths( direct, libraries, &[&main_class_path, &client_path], &java_version.architecture, minecraft_updated, )? } else { args::get_class_paths( &launch_libraries_dir, version_info.libraries.as_slice(), &[&main_class_path, &client_path], &java_version.architecture, minecraft_updated, )? }; command.args( args::get_jvm_arguments( args.get(&d::minecraft::ArgumentType::Jvm) .map(|x| x.as_slice()), &natives_dir, &launch_libraries_dir, &launch_log_configs_dir, &class_paths, &main_class_path, &version_jar, effective_memory, resolved_java_args.clone(), &java_version.architecture, &quick_play_type, quick_play_version, version_info .logging .as_ref() .and_then(|x| x.get(&LoggingSide::Client)), rpc_server.address(), )? .into_iter(), ); // The java launcher requires access to java.lang.reflect in order to force access in to // whatever module the main class is in if java_version.parsed_version >= 9 { command.arg("--add-opens=java.base/java.lang.reflect=ALL-UNNAMED"); } // The java launcher code requires internal JDK code in Java 25+ in order to support JEP 512 if java_version.parsed_version >= 25 { command.arg("--add-opens=java.base/jdk.internal.misc=ALL-UNNAMED"); } if let Some((agent_path, api_root, prefetched_metadata)) = yggdrasil_agent { command .arg(format!( "-javaagent:{}={api_root}", agent_path.to_string_lossy() )) .arg("-Dauthlibinjector.side=client") .arg(format!( "-Dauthlibinjector.yggdrasil.prefetched={prefetched_metadata}" )); } let launch_assets_dir = runtime.assets_dir(&state.directories); command .arg("com.modrinth.theseus.MinecraftLaunch") .arg(version_info.main_class.clone()) .args( args::get_minecraft_arguments( args.get(&d::minecraft::ArgumentType::Game) .map(|x| x.as_slice()), version_info.minecraft_arguments.as_deref(), credentials, &launch_version_id, &version_info.asset_index.id, &instance_path, &launch_assets_dir, &launch_version_type, effective_resolution, &java_version.architecture, &quick_play_type, quick_play_version, ) .await? .into_iter() .map(encode_game_argument), ) // Linked-launcher game arguments (PCL VersionAdvanceGame, HMCL // auto-connect server) ride at the tail of the vanilla arguments. .args(extra_game_args) .current_dir(instance_path.clone()); // CARGO-set DYLD_LIBRARY_PATH breaks Minecraft on macOS during testing on playground #[cfg(target_os = "macos")] if std::env::var("CARGO").is_ok() { command.env_remove("DYLD_FALLBACK_LIBRARY_PATH"); } // Java options should be set in instance options (the existence of _JAVA_OPTIONS overwrites them) command.env_remove("_JAVA_OPTIONS"); command.envs(env_args); #[cfg(target_os = "linux")] command.envs(high_performance_gpu_environment); // Overwrites the minecraft options.txt file with the settings from the profile // Uses 'a:b' syntax which is not quite yaml // Directly associated instances never write into the linked folder. let options_path = instance_path.join("options.txt"); let options_existed = options_path.exists(); if direct_launch.is_none() && (!mc_set_options.is_empty() || offline_skin_pack.enabled_pack_id.is_some() || options_existed || !settings.locale.is_empty()) { let (mut options_string, input_encoding) = if options_existed { io::read_any_encoding_to_string(&options_path).await? } else { (String::new(), encoding_rs::UTF_8) }; // UTF-16 encodings may be successfully detected and read, but we cannot encode // them back, and it's technically possible that the game client strongly expects // such encoding if input_encoding != input_encoding.output_encoding() { return Err(crate::ErrorKind::LauncherError(format!( "The instance options.txt file uses an unsupported encoding: {}. \ Please either turn off instance options that need to modify this file, \ or convert the file to an encoding that both the game and this app support, \ such as UTF-8.", input_encoding.name() )) .into()); } let language_options = language::game_language_options( &settings.locale, launch_release_time, &options_string, instance_path.join("saves").exists(), ); if !mc_set_options.is_empty() || !language_options.is_empty() || offline_skin_pack.enabled_pack_id.is_some() || options_existed { for (key, value) in mc_set_options.iter().chain(language_options.iter()) { let re = Regex::new(&format!(r"(?m)^{}:.*$", regex::escape(key)))?; // check if the regex exists in the file if !re.is_match(&options_string) { // The key was not found in the file, so append it write!(&mut options_string, "\n{key}:{value}").unwrap(); } else { let replaced_string = re .replace_all(&options_string, &format!("{key}:{value}")) .to_string(); options_string = replaced_string; } } update_offline_skin_resource_pack_option( &mut options_string, offline_skin_pack, )?; io::write(&options_path, input_encoding.encode(&options_string).0) .await?; } } crate::state::instances::commands::set_instance_last_played( &instance.id, Utc::now(), &state.pool, ) .await?; // The launcher log must land where the game and log browser read it. The // resolved directory is PCL's PathIndie equivalent for direct links. let logs_folder = state.directories.game_logs_dir(&instance_path); // Create Minecraft child by inserting it into the state // This also spawns the process and prepares the subsequent processes let __launch_process = state .process_manager .insert_new_process( &instance.id, &instance.path, &instance.name, command, post_exit_hook, maximize_window, instance_path.clone(), logs_folder, version_info.logging.is_some(), main_class_keep_alive, rpc_server, async |process: &ProcessMetadata, rpc_server| { let process_start_time = process.start_time.to_rfc3339(); let instance_created_time = instance.created.to_rfc3339(); let instance_modified_time = instance.modified.to_rfc3339(); let (link_project_id, link_version_id) = link_project_and_version(&context.link); let system_properties = [ ("modrinth.process.startTime", Some(&process_start_time)), ("modrinth.profile.created", Some(&instance_created_time)), ("modrinth.profile.icon", instance.icon_path.as_ref()), ("modrinth.profile.link.project", link_project_id), ("modrinth.profile.link.version", link_version_id), ( "modrinth.profile.modified", Some(&instance_modified_time), ), ("modrinth.profile.name", Some(&instance.name)), ]; for (key, value) in system_properties { let Some(value) = value else { continue; }; rpc_server .call_method_2::<()>("set_system_property", key, value) .await?; } rpc_server.call_method::<()>("launch").await?; Ok(()) }, ) .await; tracing::info!("[launch-timing] process_spawn: {}ms", __lt.elapsed().as_millis()); __launch_process } #[cfg(test)] mod game_argument_encoding_tests { use super::*; #[test] fn ascii_game_argument_stays_unchanged() { assert_eq!( encode_game_argument("--username".to_string()), "--username" ); } #[test] fn non_ascii_game_argument_uses_ascii_transport() { let original = r"E:\Games\Minecraft\profiles\Prominence™ II"; let encoded = encode_game_argument(original.to_string()); assert!(encoded.is_ascii()); let payload = encoded .strip_prefix(UTF8_GAME_ARGUMENT_PREFIX) .expect("non-ASCII argument should be encoded"); assert_eq!( BASE64_STANDARD.decode(payload).unwrap(), original.as_bytes() ); } #[test] fn reserved_prefix_is_escaped() { let original = format!("{UTF8_GAME_ARGUMENT_PREFIX}literal"); let encoded = encode_game_argument(original.clone()); let payload = encoded.strip_prefix(UTF8_GAME_ARGUMENT_PREFIX).unwrap(); assert_eq!( BASE64_STANDARD.decode(payload).unwrap(), original.as_bytes() ); } } fn update_offline_skin_resource_pack_option( options_string: &mut String, offline_skin_pack: crate::minecraft_skins::OfflineSkinPackOptions, ) -> crate::Result<()> { use crate::minecraft_skins::{ OFFLINE_SKIN_PACK_LEGACY_ID, OFFLINE_SKIN_PACK_MODERN_ID, }; let resource_packs = Regex::new(r"(?m)^resourcePacks:(.*)$")?; let existing_value = resource_packs .captures(options_string) .and_then(|captures| captures.get(1)) .map_or("[]", |value| value.as_str().trim()); if offline_skin_pack.enabled_pack_id.is_none() && !existing_value.contains(OFFLINE_SKIN_PACK_LEGACY_ID) && !existing_value.contains(OFFLINE_SKIN_PACK_MODERN_ID) { return Ok(()); } let Ok(mut packs) = serde_json::from_str::>(existing_value) else { tracing::warn!( "Skipping offline skin resource-pack option update because resourcePacks in options.txt is malformed" ); return Ok(()); }; packs.retain(|pack| { pack != OFFLINE_SKIN_PACK_LEGACY_ID && pack != OFFLINE_SKIN_PACK_MODERN_ID }); if let Some(pack_id) = offline_skin_pack.enabled_pack_id && !packs.iter().any(|pack| pack == pack_id) { if pack_id == OFFLINE_SKIN_PACK_MODERN_ID && !packs.iter().any(|pack| pack == "vanilla") { packs.insert(0, "vanilla".to_string()); } packs.push(pack_id.to_string()); } let value = serde_json::to_string(&packs)?; if resource_packs.is_match(options_string) { *options_string = resource_packs .replace_all(options_string, format!("resourcePacks:{value}")) .to_string(); } else if options_string.is_empty() { write!(options_string, "resourcePacks:{value}").unwrap(); } else { write!(options_string, "\nresourcePacks:{value}").unwrap(); } Ok(()) } #[cfg(test)] mod loader_resolution_tests { use super::*; use daedalus::minecraft::RuleAction; #[test] fn disallow_only_library_rules_are_not_downloaded() { let rules = [d::minecraft::Rule { action: RuleAction::Disallow, os: None, features: None, }]; assert!(!parse_rules(&rules, "8", &QuickPlayType::None, false,)); assert!(parse_rules(&[], "8", &QuickPlayType::None, false,)); } fn loader_version(id: &str, stable: bool) -> LoaderVersion { LoaderVersion { id: id.to_string(), url: format!("https://example.invalid/{id}"), stable, profile_source: Default::default(), fallback_url: None, } } #[test] fn liteloader_runtime_detection_uses_the_maven_coordinate() { assert!(is_liteloader_library( "com.mumfrey:liteloader:1.12.2-SNAPSHOT" )); assert!(!is_liteloader_library("example:liteloader:1.12.2-SNAPSHOT")); assert!(!is_liteloader_library("com.mumfrey:liteloader")); } #[test] fn repair_exact_loader_selection_never_substitutes_or_persists_fallback() { let versions = vec![ loader_version("47.4.22", true), loader_version("47.2.0", false), ]; assert_eq!( select_loader_version(&versions, Some("47.2.0")) .map(|version| version.id.as_str()), Some("47.2.0") ); assert!(select_loader_version(&versions, Some("47.1.0")).is_none()); assert_eq!( select_loader_version(&versions, None) .map(|version| version.id.as_str()), Some("47.4.22") ); assert_eq!(explicit_loader_version(Some("47.1.0")), Some("47.1.0")); assert_eq!(explicit_loader_version(None), None); assert_eq!(explicit_loader_version(Some("latest")), None); assert_eq!(explicit_loader_version(Some("stable")), None); assert_eq!( missing_loader_version_fallback( ModLoader::Forge, "1.20.1", Some("47.1.0") ), Err("Loader version 47.1.0 is not available for forge 1.20.1" .to_string()) ); assert_eq!( missing_loader_version_fallback(ModLoader::Forge, "1.20.1", None), Ok("stable") ); } #[test] fn installed_instances_use_persisted_exact_loader_offline() { for loader in [ ModLoader::Fabric, ModLoader::Forge, ModLoader::NeoForge, ModLoader::Quilt, ] { let version = installed_offline_loader_version( loader, Some("persisted-exact-version"), ) .expect("installed exact loader should not require metadata"); assert_eq!(version.id, "persisted-exact-version", "{loader:?}"); assert!(version.url.is_empty(), "{loader:?}"); } assert!( installed_offline_loader_version(ModLoader::Forge, Some("latest")) .is_none() ); assert!( installed_offline_loader_version(ModLoader::Forge, None).is_none() ); } } #[cfg(test)] mod processor_output_tests { use super::*; use std::collections::HashMap; fn processor( path: impl Into, hash: impl Into, ) -> d::modded::Processor { d::modded::Processor { jar: "example:processor:1.0".to_string(), classpath: Vec::new(), args: Vec::new(), outputs: Some(HashMap::from([(path.into(), hash.into())])), sides: None, } } fn data( path: impl Into, hash: impl Into, ) -> HashMap { HashMap::from([ ( "OUTPUT".to_string(), d::modded::SidedDataEntry { client: path.into(), server: String::new(), }, ), ( "HASH".to_string(), d::modded::SidedDataEntry { client: hash.into(), server: String::new(), }, ), ]) } #[tokio::test] async fn valid_processor_output_skips_execution() { let directory = tempfile::tempdir().unwrap(); let libraries = directory.path().join("libraries"); tokio::fs::create_dir_all(&libraries).await.unwrap(); let output = libraries.join("output.jar"); tokio::fs::write(&output, b"valid processor output") .await .unwrap(); let hash = sha1_smol::Sha1::from(b"valid processor output").hexdigest(); let data = data(output.to_string_lossy(), format!("'{hash}'")); assert!( processor_outputs_are_current( &processor("{OUTPUT}", "{HASH}"), &libraries, &data, &[&libraries], ) .await ); } #[tokio::test] async fn binary_patcher_uses_the_matching_output_sha() { let directory = tempfile::tempdir().unwrap(); let libraries = directory.path().join("libraries"); tokio::fs::create_dir_all(&libraries).await.unwrap(); let output = libraries.join("patched.jar"); tokio::fs::write(&output, b"patched minecraft") .await .unwrap(); let hash = sha1_smol::Sha1::from(b"patched minecraft").hexdigest(); let data = HashMap::from([ ( "PATCHED".to_string(), d::modded::SidedDataEntry { client: output.to_string_lossy().into_owned(), server: String::new(), }, ), ( "PATCHED_SHA".to_string(), d::modded::SidedDataEntry { client: format!("'{hash}'"), server: String::new(), }, ), ]); let processor = d::modded::Processor { jar: "net.minecraftforge:binarypatcher:1.1.1".to_string(), classpath: Vec::new(), args: vec!["--output".to_string(), "{PATCHED}".to_string()], outputs: None, sides: None, }; assert!( processor_outputs_are_current( &processor, &libraries, &data, &[&libraries], ) .await ); } #[tokio::test] async fn missing_processor_output_runs_execution() { let directory = tempfile::tempdir().unwrap(); let libraries = directory.path().join("libraries"); tokio::fs::create_dir_all(&libraries).await.unwrap(); let output = libraries.join("missing.jar"); let data = data( output.to_string_lossy(), "0000000000000000000000000000000000000000", ); assert!( !processor_outputs_are_current( &processor("{OUTPUT}", "{HASH}"), &libraries, &data, &[&libraries], ) .await ); } #[tokio::test] async fn wrong_processor_output_hash_runs_execution() { let directory = tempfile::tempdir().unwrap(); let libraries = directory.path().join("libraries"); tokio::fs::create_dir_all(&libraries).await.unwrap(); let output = libraries.join("output.jar"); tokio::fs::write(&output, b"wrong hash").await.unwrap(); let data = data( output.to_string_lossy(), "0000000000000000000000000000000000000000", ); assert!( !processor_outputs_are_current( &processor("{OUTPUT}", "{HASH}"), &libraries, &data, &[&libraries], ) .await ); } #[tokio::test] async fn malformed_or_escaped_processor_outputs_run_execution() { let directory = tempfile::tempdir().unwrap(); let libraries = directory.path().join("libraries"); tokio::fs::create_dir_all(&libraries).await.unwrap(); let outside = directory.path().join("outside.jar"); tokio::fs::write(&outside, b"outside").await.unwrap(); let hash = sha1_smol::Sha1::from(b"outside").hexdigest(); let escaped_data = data(outside.to_string_lossy(), &hash); assert!( !processor_outputs_are_current( &processor("{OUTPUT}", "{HASH}"), &libraries, &escaped_data, &[&libraries], ) .await ); let malformed_data = data(outside.to_string_lossy(), "not-a-sha1"); assert!( !processor_outputs_are_current( &processor("{OUTPUT}", "{HASH}"), &libraries, &malformed_data, &[directory.path()], ) .await ); } } #[cfg(test)] mod offline_skin_resource_pack_tests { use super::*; #[test] fn enables_offline_skin_pack_when_options_file_is_new() { let mut options = String::new(); update_offline_skin_resource_pack_option( &mut options, crate::minecraft_skins::OfflineSkinPackOptions { enabled_pack_id: Some( crate::minecraft_skins::OFFLINE_SKIN_PACK_MODERN_ID, ), }, ) .unwrap(); assert_eq!( options, "resourcePacks:[\"vanilla\",\"file/Axolotl Offline Skin.zip\"]" ); } #[test] fn adds_modern_offline_skin_pack_without_removing_existing_packs() { let mut options = "resourcePacks:[\"vanilla\",\"file/user-pack.zip\"]".to_string(); update_offline_skin_resource_pack_option( &mut options, crate::minecraft_skins::OfflineSkinPackOptions { enabled_pack_id: Some( crate::minecraft_skins::OFFLINE_SKIN_PACK_MODERN_ID, ), }, ) .unwrap(); assert_eq!( options, "resourcePacks:[\"vanilla\",\"file/user-pack.zip\",\"file/Axolotl Offline Skin.zip\"]" ); } #[test] fn removes_both_offline_skin_pack_id_variants() { let mut options = "resourcePacks:[\"vanilla\",\"Axolotl Offline Skin.zip\",\"file/Axolotl Offline Skin.zip\"]".to_string(); update_offline_skin_resource_pack_option( &mut options, crate::minecraft_skins::OfflineSkinPackOptions { enabled_pack_id: None, }, ) .unwrap(); assert_eq!(options, "resourcePacks:[\"vanilla\"]"); } } #[cfg(test)] mod linked_rule_tests { use super::*; use d::minecraft::{FeatureRule, OsRule, Rule}; fn rule( action: RuleAction, os: Option, features: Option, ) -> Rule { Rule { action, os, features, } } #[test] fn ordered_rules_use_the_last_matching_action() { let rules = [ rule(RuleAction::Allow, None, None), rule(RuleAction::Disallow, None, None), rule(RuleAction::Allow, None, None), ]; assert!(parse_rules( &rules, std::env::consts::ARCH, &QuickPlayType::None, true )); assert!(!parse_rules( &rules[..2], std::env::consts::ARCH, &QuickPlayType::None, true )); assert!(parse_rules( &[], std::env::consts::ARCH, &QuickPlayType::None, true )); } #[test] fn os_and_features_on_one_rule_are_conjunctive() { let matching_os = OsRule { name: Some(d::minecraft::Os::native_arch(std::env::consts::ARCH)), version: None, arch: Some(std::env::consts::ARCH.to_string()), }; let custom_resolution = FeatureRule { is_demo_user: None, has_custom_resolution: Some(true), has_quick_plays_support: None, is_quick_play_singleplayer: None, is_quick_play_multiplayer: None, is_quick_play_realms: None, }; let matching_rule = rule( RuleAction::Allow, Some(matching_os), Some(custom_resolution), ); assert!(parse_rules( &[matching_rule], std::env::consts::ARCH, &QuickPlayType::None, true )); let mismatched = rule( RuleAction::Allow, Some(OsRule { name: Some(d::minecraft::Os::native_arch( std::env::consts::ARCH, )), version: None, arch: Some("definitely-not-this-architecture".to_string()), }), Some(FeatureRule { is_demo_user: None, has_custom_resolution: Some(true), has_quick_plays_support: None, is_quick_play_singleplayer: None, is_quick_play_multiplayer: None, is_quick_play_realms: None, }), ); assert!(!parse_rules( &[mismatched], std::env::consts::ARCH, &QuickPlayType::None, true )); } }