Files
Starlight_Lancher/packages/app-lib/src/launcher/direct_link.rs
T
Disy ce0f41c973
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Sync LobeHub models / sync (push) Has been cancelled
fix: 修复实时日志与数据库切换可靠性
2026-09-27 14:18:25 +08:00

3488 lines
124 KiB
Rust

//! Launcher-dialect resolution and launch glue for directly linked Minecraft
//! installations. Linked folders are read in place; native archives are the
//! only data copied, and they are extracted into Axolotl's own cache.
use std::io::Write;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use daedalus::minecraft::{
AssetIndex, Library, LibraryDownload, Os, VersionInfo, VersionType,
};
use daedalus::modded::normalize_loader_libraries;
use super::local_version::{
HmclVersionSettings, LinkedLibrary, MergedVersion, discover_version_json,
load_version_for_dialect,
};
use crate::state::{Instance, MemorySettings, ModLoader, WindowSize};
/// Selects where a conventional external `.minecraft` installation stores
/// instance-owned content. `Automatic` is retained only for legacy links and
/// follows the owning launcher's original configuration.
#[derive(
Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize,
)]
#[serde(rename_all = "snake_case")]
pub enum ExternalGameDirMode {
Automatic,
Isolated,
Shared,
}
impl ExternalGameDirMode {
pub fn parse(value: &str) -> crate::Result<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"automatic" => Ok(Self::Automatic),
"isolated" => Ok(Self::Isolated),
"shared" => Ok(Self::Shared),
value => Err(crate::ErrorKind::LauncherError(format!(
"Unsupported external Minecraft directory mode: {value}"
))
.into()),
}
}
pub fn key(self) -> &'static str {
match self {
Self::Automatic => "automatic",
Self::Isolated => "isolated",
Self::Shared => "shared",
}
}
}
/// Returns the conventional version-metadata directory for an ordinary
/// instance created against an external `.minecraft` root. The game working
/// directory may be either this version directory or the root itself.
pub(crate) fn external_version_dir_for_game_override(
instance: &Instance,
) -> Option<(PathBuf, ExternalGameDirMode)> {
let game_dir_override = instance.game_dir_override.as_deref()?;
let game_dir = PathBuf::from(game_dir_override);
if game_dir
.parent()
.and_then(Path::file_name)
.and_then(|name| name.to_str())
.is_some_and(|name| name.eq_ignore_ascii_case("versions"))
{
return Some((game_dir, ExternalGameDirMode::Isolated));
}
game_dir
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.eq_ignore_ascii_case(".minecraft"))
.then(|| {
(
game_dir.join("versions").join(&instance.name),
ExternalGameDirMode::Shared,
)
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LinkedLauncherDialect {
Hmcl,
Pcl,
PclCe,
Generic,
}
impl LinkedLauncherDialect {
pub fn parse(value: &str) -> crate::Result<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"hmcl" => Ok(Self::Hmcl),
"pcl" | "pcl2" => Ok(Self::Pcl),
"pcl_ce" | "pcl2_ce" | "pcl2ce" => Ok(Self::PclCe),
"generic" => Ok(Self::Generic),
value => Err(crate::ErrorKind::LauncherError(format!(
"Unsupported linked launcher dialect: {value}"
))
.into()),
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct DirectLinkedLaunch {
pub dot_minecraft: PathBuf,
pub launcher_root: Option<PathBuf>,
pub version_id: String,
pub version_json: Option<PathBuf>,
pub dialect: LinkedLauncherDialect,
pub game_dir_mode: Option<ExternalGameDirMode>,
}
pub(crate) struct ResolvedLinkedLaunch {
pub merged: MergedVersion,
pub game_dir: PathBuf,
}
impl DirectLinkedLaunch {
/// Builds a generic direct link for an instance created in
/// .minecraft/versions/<id>. This is intentionally derived at runtime
/// instead of persisted in the database, preserving compatibility for
/// instances created before external direct management was added.
pub(crate) fn from_external_version_dir(
version_dir: &Path,
) -> crate::Result<Option<Self>> {
let Some(folder_name) =
version_dir.file_name().and_then(|name| name.to_str())
else {
return Ok(None);
};
if version_dir
.parent()
.and_then(Path::file_name)
.and_then(|name| name.to_str())
.is_none_or(|name| !name.eq_ignore_ascii_case("versions"))
{
return Ok(None);
}
let Some(dot_minecraft) = version_dir.parent().and_then(Path::parent)
else {
return Ok(None);
};
// Older externally-created records contain only `game_dir_override`.
// Do not assume their display folder and manifest stem match: PCL and
// copied installations commonly keep a user-facing folder name while
// the actual JSON is named after the version ID. Resolving the exact
// manifest here keeps those rows on the external runtime adapter,
// instead of later falling back to Axolotl's managed libraries.
let version_json = discover_version_json(dot_minecraft, folder_name)?;
let version_id = version_json
.file_stem()
.and_then(|stem| stem.to_str())
.filter(|stem| !stem.is_empty())
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Version JSON has no usable file stem: {}",
version_json.display()
))
})?;
Ok(Some(Self {
dot_minecraft: crate::util::io::canonicalize(dot_minecraft)?,
launcher_root: None,
version_id: version_id.to_string(),
version_json: Some(crate::util::io::canonicalize(version_json)?),
dialect: LinkedLauncherDialect::Generic,
game_dir_mode: Some(ExternalGameDirMode::Isolated),
}))
}
/// Builds a direct link for a newly created external instance before its
/// link metadata has been persisted. This handles both the conventional
/// version-isolated override and the shared `.minecraft` root override.
pub(crate) fn from_game_dir_override(
instance: &Instance,
) -> crate::Result<Option<Self>> {
let Some((version_dir, game_dir_mode)) =
external_version_dir_for_game_override(instance)
else {
return Ok(None);
};
let Some(mut direct) = Self::from_external_version_dir(&version_dir)?
else {
return Ok(None);
};
direct.game_dir_mode = Some(game_dir_mode);
Ok(Some(direct))
}
pub(crate) fn from_instance(
instance: &Instance,
) -> crate::Result<Option<Self>> {
let fields = (
instance.linked_launcher.as_deref(),
instance.linked_dot_minecraft.as_deref(),
instance.linked_version_id.as_deref(),
);
let has_any_linked_metadata = fields.0.is_some()
|| fields.1.is_some()
|| fields.2.is_some()
|| instance.linked_launcher_root.is_some()
|| instance.linked_version_json_path.is_some();
if !has_any_linked_metadata {
return Ok(None);
}
let (launcher, dot_minecraft, version_id) = match fields {
(Some(launcher), Some(dot_minecraft), Some(version_id))
if !launcher.trim().is_empty()
&& !dot_minecraft.trim().is_empty()
&& !version_id.trim().is_empty() =>
{
(launcher, dot_minecraft, version_id)
}
_ => {
return Err(crate::ErrorKind::LauncherError(
"Directly linked instance metadata is incomplete"
.to_string(),
)
.into());
}
};
Ok(Some(Self {
dot_minecraft: PathBuf::from(dot_minecraft),
launcher_root: instance
.linked_launcher_root
.as_deref()
.map(PathBuf::from),
version_id: version_id.to_string(),
version_json: instance
.linked_version_json_path
.as_deref()
.map(PathBuf::from),
dialect: LinkedLauncherDialect::parse(launcher)?,
game_dir_mode: instance
.linked_game_dir_mode
.as_deref()
.map(ExternalGameDirMode::parse)
.transpose()?,
}))
}
pub(crate) fn resolve(&self) -> crate::Result<ResolvedLinkedLaunch> {
let merged = load_version_for_dialect(
&self.dot_minecraft,
&self.version_id,
self.version_json.as_deref(),
self.dialect,
)?;
let game_dir = match self.game_dir_mode {
Some(ExternalGameDirMode::Isolated) => self.version_dir(),
Some(ExternalGameDirMode::Shared) => self.dot_minecraft.clone(),
Some(ExternalGameDirMode::Automatic) | None => match self.dialect {
LinkedLauncherDialect::Pcl | LinkedLauncherDialect::PclCe => {
resolve_pcl_game_dir(
&self.dot_minecraft,
&self.version_dir(),
self.dialect,
&merged,
self.launcher_root.as_deref(),
)?
}
LinkedLauncherDialect::Hmcl => resolve_hmcl_game_dir(
self.launcher_root.as_deref(),
&self.dot_minecraft,
&self.version_dir(),
)?,
// A generic direct link is the `.minecraft/versions/<id>` format;
// its content is always isolated beside the version metadata,
// including when the directory is currently empty.
LinkedLauncherDialect::Generic => self.version_dir(),
},
};
Ok(ResolvedLinkedLaunch { merged, game_dir })
}
pub(crate) fn libraries_dir(&self) -> PathBuf {
self.dot_minecraft.join("libraries")
}
pub(crate) fn assets_dir(&self) -> PathBuf {
self.dot_minecraft.join("assets")
}
pub(crate) fn log_configs_dir(&self) -> PathBuf {
self.assets_dir().join("log_configs")
}
pub(crate) fn version_dir(&self) -> PathBuf {
self.version_json
.as_deref()
.and_then(Path::parent)
.map(Path::to_path_buf)
.unwrap_or_else(|| {
self.dot_minecraft.join("versions").join(&self.version_id)
})
}
pub(crate) fn client_jar(&self, jar_id: &str) -> PathBuf {
if jar_id == self.version_id {
let version_dir = self.version_dir();
let by_id = version_dir.join(format!("{jar_id}.jar"));
if by_id.is_file() {
return by_id;
}
if let Some(folder_name) =
version_dir.file_name().and_then(|name| name.to_str())
{
let by_folder = version_dir.join(format!("{folder_name}.jar"));
if by_folder.is_file() {
return by_folder;
}
}
by_id
} else {
let inherited = self
.dot_minecraft
.join("versions")
.join(jar_id)
.join(format!("{jar_id}.jar"));
if inherited.is_file() {
return inherited;
}
let version_dir = self.version_dir();
if let Some(folder_name) =
version_dir.file_name().and_then(|name| name.to_str())
{
let by_folder = version_dir.join(format!("{folder_name}.jar"));
if by_folder.is_file() {
return by_folder;
}
}
inherited
}
}
pub(crate) fn library_path(
&self,
library: &LinkedLibrary,
) -> crate::Result<PathBuf> {
if library.hint.as_deref() == Some("local") {
let relative =
library.filename.as_deref().map(PathBuf::from).unwrap_or(
maven_artifact_filename(&library.library.name)?.into(),
);
Ok(self.version_dir().join("libraries").join(relative))
} else {
Ok(self
.libraries_dir()
.join(library.classpath_relative_path()?))
}
}
}
/// HMCL commonly keeps an isolated instance's content beside its version JSON
/// without a launcher-specific config file. Prefer that directory when it
/// contains instance-owned content; otherwise use the shared `.minecraft`
/// root. Generic direct links use the isolated directory unconditionally.
fn resolve_content_game_dir(
dot_minecraft: &Path,
version_dir: &Path,
) -> crate::Result<PathBuf> {
for name in [
"mods",
"config",
"saves",
"resourcepacks",
"shaderpacks",
"datapacks",
"schematics",
] {
let path = version_dir.join(name);
if path.is_dir() {
return Ok(version_dir.to_path_buf());
}
}
Ok(dot_minecraft.to_path_buf())
}
fn resolve_hmcl_game_dir(
launcher_root: Option<&Path>,
dot_minecraft: &Path,
version_dir: &Path,
) -> crate::Result<PathBuf> {
if let Some(launcher_root) = launcher_root
&& let Some(game_dir) =
crate::api::pack::import::hmcl::configured_game_dir(
launcher_root,
dot_minecraft,
version_dir,
)
{
return Ok(game_dir);
}
resolve_content_game_dir(dot_minecraft, version_dir)
}
/// Applies the managed-install loader normalization to a directly linked
/// merge, reusing the shared `daedalus::modded::normalize_loader_libraries`
/// rule without duplicating its coordinates.
///
/// Only Cleanroom-linked versions are affected: the shared rule drops the
/// whole vanilla LWJGL 2 family at `2.9.4-nightly-20150209` from the merged
/// list — the `org.lwjgl.lwjgl:lwjgl` binding, `org.lwjgl.lwjgl:lwjgl_util`,
/// and the natives-only carrier `org.lwjgl.lwjgl:lwjgl-platform` whose
/// root-level extracted `lwjgl.dll` / `liblwjgl.so` / `liblwjgl.dylib` is
/// what `System.loadLibrary("lwjgl")` (org.lwjgl.Sys) resolves first in the
/// natives directory, while the Cleanroom LWJGL 3 natives are nested.
/// HMCL never classpaths native jars and its first-wins unzip protects its
/// natives directory; a direct link merges the vanilla parent after the
/// loader (last-writer-wins extraction) and ships the old root-level
/// LWJGL 2 native, so the carrier must not reach the extraction pass at
/// all. The merged list drives the direct ensure pass, native extraction,
/// the launch classpath, and the `VersionInfo` projection, so normalizing
/// it before those consumers run covers direct ensure, classpath, and
/// launch at once. The Cleanroom LWJGL 3 line and `com.cleanroommc:lwjglxx`
/// are untouched; the vanilla JNA platform and the Mojang ICU bundle are
/// removed by the pre-existing Axolotl Cleanroom rule (not HMCL logic).
/// Returns the removed coordinates for logging.
pub(crate) fn normalize_merged_loader_libraries(
loader: ModLoader,
merged: &mut MergedVersion,
) -> Vec<String> {
// The frozen content-set label can mislabel a directly linked Cleanroom
// installation as vanilla when the association predates Cleanroom loader
// detection. Fall back only for a strictly vanilla label: a merged
// library carrying the Cleanroom loader jar itself (`com.cleanroommc:cleanroom`)
// is then authoritative. Any other frozen label (Forge and friends) keeps
// the launcher's own classification untouched.
let is_cleanroom = loader == ModLoader::Cleanroom
|| (loader == ModLoader::Vanilla
&& merged.libraries.iter().any(|library| {
library
.library
.name
.starts_with("com.cleanroommc:cleanroom:")
}));
if !is_cleanroom {
return Vec::new();
}
// The linked list wraps daedalus libraries with community-launcher
// fields; evaluate the shared rule against a plain projection, then
// drop the same coordinates from the linked list in place.
let mut projection: Vec<Library> = merged
.libraries
.iter()
.map(|library| library.library.clone())
.collect();
let removed =
normalize_loader_libraries("cleanroom", &merged.id, &mut projection);
if !removed.is_empty() {
let removed_names: std::collections::HashSet<&str> =
removed.iter().map(String::as_str).collect();
merged.libraries.retain(|library| {
!removed_names.contains(library.library.name.as_str())
});
}
removed
}
pub(crate) fn merged_to_version_info(
mut merged: MergedVersion,
) -> crate::Result<VersionInfo> {
let main_class = merged.main_class.take().ok_or_else(|| {
crate::ErrorKind::LauncherError(format!(
"Local version \"{}\" does not declare a mainClass",
merged.id
))
})?;
let asset_id = merged
.assets
.clone()
.or_else(|| merged.asset_index.as_ref().map(|index| index.id.clone()))
.unwrap_or_else(|| "legacy".to_string());
let asset_index = merged.asset_index.take().unwrap_or_else(|| AssetIndex {
id: asset_id.clone(),
sha1: String::new(),
size: 0,
total_size: 0,
url: String::new(),
});
Ok(VersionInfo {
arguments: merged.arguments.take(),
assets: asset_id,
asset_index,
downloads: merged.downloads.take().unwrap_or_default(),
id: merged.id,
java_version: merged.java_version.take(),
libraries: std::mem::take(&mut merged.libraries)
.into_iter()
.map(|library| library.library)
.collect(),
logging: merged.logging.take(),
main_class,
minecraft_arguments: merged.minecraft_arguments.take(),
minimum_launcher_version: merged.minimum_launcher_version.unwrap_or(0),
release_time: merged.release_time.unwrap_or(DateTime::<Utc>::MIN_UTC),
time: merged.time.unwrap_or(DateTime::<Utc>::MIN_UTC),
type_: merged.type_.take().unwrap_or(VersionType::Release),
data: None,
processors: None,
})
}
pub(crate) fn conservative_launch_facts(
merged: &MergedVersion,
) -> (bool, super::QuickPlayVersion) {
use super::quick_play_version::{
QuickPlayServerVersion, QuickPlaySingleplayerVersion,
};
let modern = merged
.java_version
.as_ref()
.map_or(true, |java| java.major_version >= 17);
(
modern,
super::QuickPlayVersion {
server: QuickPlayServerVersion::Unsupported,
singleplayer: QuickPlaySingleplayerVersion::Unsupported,
},
)
}
/// Rust port of HMCL `StringUtils.tokenize` (commit
/// `083dbb18ade1c935e2e56d0bdefcd718be1e2ed6`, invoked without variables),
/// which is how HMCL splits its javaArgs string: arguments separate on spaces,
/// single- and double-quoted segments stay one argument with the quotes
/// stripped, an unclosed quote runs to the end of the string, backslashes are
/// always literal, and inside double quotes a backtick escapes the next
/// character (letters expanding to their C-style control characters).
fn hmcl_tokenize(input: &str) -> Vec<String> {
let chars: Vec<char> = input.chars().collect();
let mut tokens = Vec::new();
let mut current = String::new();
let mut has_value = false;
let mut i = 0;
while i < chars.len() {
match chars[i] {
'\'' => {
has_value = true;
let end = chars[i + 1..]
.iter()
.position(|&ch| ch == '\'')
.map_or(chars.len(), |offset| i + 1 + offset);
current.extend(&chars[i + 1..end]);
i = end + 1;
}
'"' => {
has_value = true;
i += 1;
while i < chars.len() {
let character = chars[i];
i += 1;
match character {
'"' => break,
'`' if i < chars.len() => {
let escaped = chars[i];
i += 1;
current.push(match escaped {
'a' => '\u{7}',
'b' => '\u{8}',
'f' => '\u{c}',
'n' => '\n',
'r' => '\r',
't' => '\t',
'v' => '\u{b}',
other => other,
});
}
_ => current.push(character),
}
}
}
' ' => {
if has_value {
tokens.push(std::mem::take(&mut current));
has_value = false;
}
i += 1;
}
character => {
has_value = true;
current.push(character);
i += 1;
}
}
}
if has_value {
tokens.push(current);
}
tokens
}
/// Applies the HMCL private per-version settings that map onto Axolotl's
/// launch configuration. `java_major` is the major version of the JVM chosen
/// for this launch; `None` means it could not be determined.
///
/// Returns extra *game* arguments that must be appended after the vanilla
/// game arguments (currently the auto-connect server mapping).
pub(crate) fn apply_hmcl_settings(
settings: &HmclVersionSettings,
memory: &mut MemorySettings,
resolution: &mut WindowSize,
java_args: &mut Vec<String>,
java_major: Option<u32>,
) -> Vec<String> {
if let Some(java_args_string) = settings.java_args.as_deref() {
java_args.extend(hmcl_tokenize(java_args_string));
}
let effective_max_memory = settings.max_memory.map(|memory| memory.max(1));
if let Some(max_memory) = effective_max_memory
&& let Ok(max_memory) = u32::try_from(max_memory)
{
memory.maximum = max_memory;
}
if let Some(min_memory) = settings.min_memory {
// HMCL clamps -Xms to -Xmx (DefaultLauncher only emits -Xms when
// min <= max); we clamp the value so both bounds stay consistent.
let mut min_memory = min_memory.max(1);
if let Some(max_memory) = effective_max_memory
&& min_memory > max_memory
{
tracing::debug!(
min_memory,
max_memory,
"Clamping linked HMCL min memory to the configured maximum"
);
min_memory = max_memory;
}
if let Ok(min_memory) = u32::try_from(min_memory) {
java_args.push(format!("-Xms{min_memory}M"));
}
}
if let Some(perm_size) = settings.perm_size.as_deref()
&& !perm_size.trim().is_empty()
{
// HMCL stores permSize as a number in MiB and always concatenates the
// "m" unit when building the flag (DefaultLauncher:
// options.getMetaspace() + "m"); honor a manually written unit too.
let perm_size = perm_size.trim();
let perm_size = if !perm_size.is_empty()
&& perm_size
.chars()
.all(|character| character.is_ascii_digit())
{
format!("{perm_size}m")
} else {
perm_size.to_string()
};
// HMCL never sends a PermGen flag to Java 8+: DefaultLauncher adds
// -XX:PermSize only when the JVM's parsed version is below 8 and maps
// this setting to -XX:MetaspaceSize otherwise (PermGen was removed in
// Java 8). An undetermined major version is treated as modern so a
// legacy flag can never reach an unknown JVM.
if java_major.is_some_and(|major| major < 8) {
java_args.push(format!("-XX:PermSize={perm_size}"));
} else {
java_args.push(format!("-XX:MetaspaceSize={perm_size}"));
}
}
let width = settings.width.and_then(|width| u16::try_from(width).ok());
let height = settings
.height
.and_then(|height| u16::try_from(height).ok());
if let (Some(width), Some(height)) = (width, height) {
*resolution = WindowSize(width, height);
} else if let Some(width) = width {
resolution.0 = width;
} else if let Some(height) = height {
resolution.1 = height;
}
// Auto-connect server: HMCL's DefaultLauncher maps a configured multiplayer
// address to `--server <host> --port <port>` for versions without Quick
// Play (directly associated versions are treated conservatively as such),
// falling back to port 25565 when unset.
let mut game_args = Vec::new();
if let Some(server_ip) = settings
.server_ip
.as_deref()
.map(str::trim)
.filter(|ip| !ip.is_empty())
{
game_args.push("--server".to_string());
game_args.push(server_ip.to_string());
game_args.push("--port".to_string());
game_args.push(
settings
.server_port
.map_or("25565".to_string(), |port| port.to_string()),
);
}
// TODO(direct-link): fullscreen would require mutating options.txt in the
// externally managed game directory, which direct association forbids.
game_args
}
pub(crate) fn hmcl_java_candidates(
settings: &HmclVersionSettings,
dot_minecraft: &Path,
) -> Vec<PathBuf> {
const JAVA_BIN: &str = if cfg!(windows) { "java.exe" } else { "java" };
let mut candidates = Vec::new();
if let Some(default_java_path) = settings.default_java_path.as_deref() {
candidates.push(PathBuf::from(default_java_path));
}
if let Some(java_dir) = settings.java_dir.as_deref() {
let java_dir = PathBuf::from(java_dir);
let root = if java_dir.is_absolute() {
java_dir
} else {
dot_minecraft.join(java_dir)
};
candidates.push(root.join("bin").join(JAVA_BIN));
candidates.push(root.join(JAVA_BIN));
}
candidates
}
// ---------------------------------------------------------------------------
// PCL / PCL-CE instance-level launch preferences (P1 direct-link support)
//
// Upstream references, both pinned:
// - PCL `639de1b48a44326cbd5465579295cecf23d9056a`: instance-scope keys in
// `Pages/PageSetup/Settings.vb` (`Sources.Instance`, persisted to
// `versions/<id>/PCL/Setup.ini`), launch consumption in
// `Modules/Minecraft/ModJava.vb` (`SelectOrDownloadJava`,
// `GetJavaRequirement`) and `ModLaunch.vb`.
// - PCL-CE `aa3b81c6afb3cd1896dda271578b002066512177`:
// `PCL.Core/App/Config.cs` (`InstanceConfigGroup` / `LaunchConfigGroup`
// config item names), `Modules/Minecraft/ModJava.cs`
// (`GetInstanceJavaPreference`) and `Pages/PageInstance/
// PageInstanceSetup.xaml.cs` / `Pages/PageSetup/PageSetupLaunch.xaml.cs`
// (`GetRam`).
//
// Both dialects use the same camel-case flat key names; they only differ in
// the container file (`PCL/Setup.ini` vs `PCL/config.v1.yml`), which
// `read_flat_config` already normalizes.
// ---------------------------------------------------------------------------
/// The PCL/PCL-CE per-instance launch preferences relevant to a launch.
///
/// Instance values come from `<version dir>/PCL/{Setup.ini|config.v1.yml}`;
/// global values come from `<launcher root>/PCL/...` and may be empty when no
/// launcher root is linked or its file is unreadable.
#[derive(Debug, Clone)]
pub(crate) struct PclLaunchSettings {
dialect: LinkedLauncherDialect,
instance_values: std::collections::HashMap<String, String>,
global_values: std::collections::HashMap<String, String>,
}
impl DirectLinkedLaunch {
/// Loads the PCL/PCL-CE preference files for this instance. Missing files
/// yield empty maps; unreadable files are logged and treated as missing so
/// a broken launcher config can never block a launch.
pub(crate) fn pcl_launch_settings(&self) -> PclLaunchSettings {
let file_name = match self.dialect {
LinkedLauncherDialect::Pcl => "Setup.ini",
LinkedLauncherDialect::PclCe => "config.v1.yml",
LinkedLauncherDialect::Hmcl | LinkedLauncherDialect::Generic => {
return PclLaunchSettings {
dialect: self.dialect,
instance_values: std::collections::HashMap::default(),
global_values: std::collections::HashMap::default(),
};
}
};
// PCL keeps instance-scope settings under <version>/PCL/ and
// launcher-global settings under <launcher>/PCL/.
let read = |path: PathBuf| {
read_flat_config(&path).unwrap_or_else(|error| {
tracing::debug!(
%error,
path = %path.display(),
"Ignoring unreadable linked PCL configuration"
);
std::collections::HashMap::default()
})
};
PclLaunchSettings {
dialect: self.dialect,
instance_values: read(
self.version_dir().join("PCL").join(file_name),
),
global_values: self
.launcher_root
.as_ref()
.map_or(std::collections::HashMap::default(), |root| {
read(root.join("PCL").join(file_name))
}),
}
}
}
const PCL_JAVA_BIN: &str = if cfg!(windows) { "java.exe" } else { "java" };
/// Case-insensitive JSON object field lookup (System.Text.Json deserializes
/// with `PropertyNameCaseInsensitive = true`; see pinned PCL-CE
/// `PCL.Core/Utils/JsonCompat.cs`).
fn json_field_ci<'a>(
value: &'a serde_json::Value,
key: &str,
) -> Option<&'a str> {
value.as_object()?.iter().find_map(|(name, field)| {
(name.eq_ignore_ascii_case(key))
.then_some(field.as_str())
.flatten()
})
}
impl PclLaunchSettings {
/// Ordered Java candidates derived from the instance's Java preference,
/// consumed by the same availability check as HMCL's candidates
/// (`select_hmcl_java` in launcher/mod.rs).
///
/// If no candidate validates there, Axolotl falls back to its own Java
/// discovery. Upstream instead aborts or prompts; silently continuing is
/// deliberate so a stale linked-launcher preference never blocks a launch.
pub(crate) fn java_candidates(
&self,
version_dir: &Path,
launcher_root: Option<&Path>,
) -> Vec<PathBuf> {
let mut candidates = Vec::new();
let fall_through_to_global = self.add_instance_candidates(
&mut candidates,
version_dir,
launcher_root,
);
if fall_through_to_global {
// Priority 2 upstream: the globally selected Java
// (`LaunchArgumentJavaSelect`; PCL Settings.vb / PCL-CE Config.cs).
if let Some(global_java) =
config_string(&self.global_values, "LaunchArgumentJavaSelect")
.as_deref()
.map(str::trim)
.filter(|path| !path.is_empty() && *path != "使用全局设置")
{
candidates.push(PathBuf::from(global_java));
}
}
candidates.dedup();
candidates
}
/// Returns `true` when the global selection should also be considered.
fn add_instance_candidates(
&self,
candidates: &mut Vec<PathBuf>,
version_dir: &Path,
launcher_root: Option<&Path>,
) -> bool {
let raw_select =
config_string(&self.instance_values, "VersionArgumentJavaSelect")
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
// PCL classic gates its newer enum behind VersionArgumentJavaV2
// (ModJava.vb SelectOrDownloadJava): 0 auto, 1 explicit range,
// 2 Java bundled in the version folder, 3 forced Java.
if self.dialect == LinkedLauncherDialect::Pcl
&& let Some(v2) =
config_i64(&self.instance_values, "VersionArgumentJavaV2")
{
match v2 {
2 => {
// 使用版本文件夹中的 Java: search the version folder for a
// bundled runtime (upstream searches Instance.PathVersion).
candidates.extend(pcl_bundled_java_candidates(version_dir));
return false;
}
3 => {
// 强制指定特定 Java: the forced path is stored either as
// legacy JSON {"Path": ...} or as a plain absolute path.
if let Some(path) = raw_select
.as_deref()
.and_then(pcl_parse_legacy_java_select)
{
candidates.push(path);
} else {
tracing::debug!(
"Linked PCL version forces a specific Java but \
stores it outside Setup.ini; ignoring"
);
}
return false;
}
1 => {
// Explicit Java version range (VersionArgumentJavaRange):
// Axolotl cannot constrain its own discovery by that range
// yet, so behave like automatic selection.
// TODO(direct-link): honor VersionArgumentJavaRange by
// filtering discovered runtimes through the parsed range.
return true;
}
_ => return true, // 0/auto: continue below
}
}
// PCL-CE GetInstanceJavaPreference (ModJava.cs): an empty value means
// auto, "使用全局设置" means global, everything else is either a
// polymorphic JSON preference or a plain Java executable path.
match raw_select.as_deref() {
None | Some("使用全局设置") => true,
Some(raw) => {
let preference = serde_json::from_str::<serde_json::Value>(raw)
.ok()
.filter(|value| value.get("kind").is_some());
if let Some(preference) = preference {
match preference
.get("kind")
.and_then(serde_json::Value::as_str)
{
// {"kind":"exist","javaExePath": "..."}
Some("exist") => {
if let Some(path) =
json_field_ci(&preference, "javaExePath")
.map(PathBuf::from)
{
candidates.push(path);
}
false
}
// {"kind":"relative","relativePath": "..."} resolves
// against the launcher directory (upstream
// Basics.ExecutableDirectory), NOT the .minecraft
// folder; paths escaping it degrade to the global
// preference.
Some("relative") => {
match json_field_ci(&preference, "relativePath")
.and_then(|relative| {
pcl_resolve_relative_java(
relative,
launcher_root,
)
}) {
Some(path) => {
candidates.push(path);
false
}
None => true,
}
}
// {"kind":"global"} / {"kind":"auto"}
_ => true,
}
} else if Path::new(raw).is_absolute() {
// Legacy plain-path entry.
candidates.push(PathBuf::from(raw));
false
} else {
// Relative or unrecognized strings degrade to the global
// preference (pinned ModJava.cs GetInstanceJavaPreference).
true
}
}
}
}
/// Resolves the RAM setting to a heap size in MiB, mirroring pinned
/// PCL-CE `PageInstanceSetup.GetRam` (and classic
/// `PageInstanceSetup.xaml.vb`). `None` keeps Axolotl's own memory
/// setting.
pub(crate) fn resolve_ram_mb(
&self,
game_dir: &Path,
modable: bool,
opti_fine: bool,
available_memory_gb: f64,
is_32_bit_java: bool,
) -> Option<u32> {
// VersionRamType: 2 follow global (upstream default), 0 automatic,
// 1 manual slider (VersionRamCustom).
let ram_type =
config_i64(&self.instance_values, "VersionRamType").unwrap_or(2);
let ram_gb = match ram_type {
0 => Some(pcl_auto_ram_gb(
available_memory_gb,
pcl_mod_count(game_dir),
modable,
opti_fine,
)),
1 => Some(pcl_slider_ram_gb(
config_i64(&self.instance_values, "VersionRamCustom")
.unwrap_or(15),
)),
_ => {
// 跟随全局: resolve through the launcher-global keys; when the
// global configuration is unavailable keep Axolotl's default.
match config_i64(&self.global_values, "LaunchRamType") {
Some(0) => Some(pcl_auto_ram_gb(
available_memory_gb,
pcl_mod_count(game_dir),
modable,
opti_fine,
)),
Some(_) => Some(pcl_slider_ram_gb(
config_i64(&self.global_values, "LaunchRamCustom")
.unwrap_or(15),
)),
None => {
tracing::debug!(
"Linked PCL instance follows the global memory \
setting but no launcher-global configuration was \
found; keeping the Axolotl memory setting"
);
None
}
}
}
}?;
// 若使用 32 位 Java,则限制为 1G (pinned PCL-CE GetRam tail).
let mut ram_gb = ram_gb;
if is_32_bit_java {
ram_gb = ram_gb.min(1.0);
}
// ModLaunch.vb: TargetRam = Math.Floor(GetRam(...) * 1024) MiB.
Some((ram_gb * 1024.0).floor().max(1.0) as u32)
}
/// Custom JVM arguments (VersionAdvanceJvm, falling back to the global
/// LaunchAdvanceJvm when the instance leaves it empty — ModLaunch.vb
/// line ~1255 / PCL-CE equivalent).
pub(crate) fn extra_jvm_args(&self) -> Vec<String> {
let custom = config_string(&self.instance_values, "VersionAdvanceJvm")
.filter(|value| !value.trim().is_empty())
.or_else(|| {
config_string(&self.global_values, "LaunchAdvanceJvm")
.filter(|value| !value.trim().is_empty())
});
custom
.map(|value| pcl_split_arguments(&value))
.unwrap_or_default()
}
/// Custom game arguments (VersionAdvanceGame with the same global
/// fallback; appended after the vanilla game arguments like PCL's
/// `Arg &= " " & CustomArg`).
pub(crate) fn extra_game_args(&self) -> Vec<String> {
let custom = config_string(&self.instance_values, "VersionAdvanceGame")
.filter(|value| !value.trim().is_empty())
.or_else(|| {
config_string(&self.global_values, "LaunchAdvanceGame")
.filter(|value| !value.trim().is_empty())
});
custom
.map(|value| pcl_split_arguments(&value))
.unwrap_or_default()
}
}
/// Parses the legacy PCL Java selection value: either a JSON object carrying a
/// "Path" member (old PCL serialized `New Java(folder)` this way) or a plain
/// absolute path.
fn pcl_parse_legacy_java_select(raw: &str) -> Option<PathBuf> {
if let Ok(value) = serde_json::from_str::<serde_json::Value>(raw) {
return json_field_ci(&value, "Path")
.map(str::trim)
.filter(|path| !path.is_empty())
.map(PathBuf::from);
}
let trimmed = raw.trim();
(!trimmed.is_empty() && Path::new(trimmed).is_absolute())
.then(|| PathBuf::from(trimmed))
}
/// Resolves a PCL-CE `UseRelativePath` preference against the launcher root.
/// Paths that escape the launcher directory are rejected (pinned ModJava.cs
/// guards with `Files.IsPathWithinDirectory(absPath, ExecutableDirectory)`),
/// which makes the caller fall back to the global preference.
fn pcl_resolve_relative_java(
relative: &str,
launcher_root: Option<&Path>,
) -> Option<PathBuf> {
let relative = relative.trim();
let launcher_root = launcher_root?;
if relative.is_empty() || Path::new(relative).is_absolute() {
return None;
}
let base_components: Vec<_> =
launcher_root.components().collect::<Vec<_>>();
let candidate = launcher_root.join(relative);
// Lexical containment: walking the joined path must never climb above the
// launcher root via `..` (upstream IsPathWithinDirectory).
let mut depth = base_components.len();
for component in candidate.components().skip(base_components.len()) {
match component {
std::path::Component::Normal(_) => depth += 1,
std::path::Component::ParentDir => {
if depth == base_components.len() {
return None;
}
depth -= 1;
}
_ => {}
}
}
// The preference may name the runtime folder (upstream stores the java
// folder) or the binary itself; accept both spellings.
let lower = relative.to_ascii_lowercase();
if lower.ends_with(PCL_JAVA_BIN) {
Some(candidate)
} else {
Some(candidate.join("bin").join(PCL_JAVA_BIN))
}
}
/// Finds Java executables bundled inside a version folder (PCL classic
/// `VersionArgumentJavaV2 = 2`, ModJava.vb `SelectOrDownloadJava` case 2).
fn pcl_bundled_java_candidates(version_dir: &Path) -> Vec<PathBuf> {
let mut candidates = Vec::new();
let mut stack = vec![(version_dir.to_path_buf(), 0usize)];
while let Some((directory, depth)) = stack.pop() {
if candidates.len() >= 4 {
break;
}
let Ok(entries) = std::fs::read_dir(&directory) else {
continue;
};
let mut sub_directories = Vec::new();
for entry in entries.filter_map(Result::ok) {
let path = entry.path();
if path.is_dir() {
if path.file_name().is_some_and(|name| name == "bin") {
let binary = path.join(PCL_JAVA_BIN);
if binary.is_file() {
candidates.push(binary);
}
} else if depth < 3 {
sub_directories.push((path, depth + 1));
}
}
}
// Depth-first order keeps the first discovered runtime first.
stack.extend(sub_directories.into_iter().rev());
}
candidates
}
/// Number of files in the resolved game directory's mods folder (PCL-CE
/// GetRam counts every file; classic filters .jar/.zip/.litemod).
fn pcl_mod_count(game_dir: &Path) -> u32 {
std::fs::read_dir(game_dir.join("mods"))
.map(|entries| entries.filter_map(Result::ok).count() as u32)
.unwrap_or(0)
}
/// PCL's staged automatic memory estimate, in GB. Faithful port of pinned
/// PCL-CE `PageInstanceSetup.GetRam` automatic branch (identical algorithm in
/// classic `PageInstanceSetup.xaml.vb` / global `PageSetupLaunch.GetRam`):
/// targets grow with the mod count, each stage consumes a shrinking share of
/// the remaining available physical memory, and the result never drops below
/// the guaranteed minimum.
pub(crate) fn pcl_auto_ram_gb(
available_memory_gb: f64,
mod_count: u32,
modable: bool,
opti_fine: bool,
) -> f64 {
// 可安装 Mod 的版本 scale every target with the mod count; OptiFine-only
// versions get fixed targets; vanilla uses the smallest table.
let (ram_minimum, target1, target2, target3) = if modable {
(
0.5 + f64::from(mod_count) / 150.0,
1.5 + f64::from(mod_count) / 90.0,
2.7 + f64::from(mod_count) / 50.0,
4.5 + f64::from(mod_count) / 25.0,
)
} else if opti_fine {
(0.5, 1.5, 3.0, 5.0)
} else {
(0.5, 1.5, 2.5, 4.0)
};
let mut ram_available = (available_memory_gb * 10.0).round() / 10.0;
let mut ram_give = 0.0;
for (delta, ratio) in [
(target1, 1.0),
(target2 - target1, 0.7),
(target3 - target2, 0.4),
(target3, 0.15),
] {
ram_give += (ram_available * ratio).min(delta);
ram_available -= delta / ratio;
if ram_available < 0.1 {
break;
}
}
(ram_give.max(ram_minimum) * 10.0).round() / 10.0
}
/// Maps PCL's manual slider value (VersionRamCustom/LaunchRamCustom) to GB.
fn pcl_slider_ram_gb(value: i64) -> f64 {
// Upstream slider steps: 0-12 → 0.1 GiB increments from 0.3, then coarser
// bands up to (value-33)*2+16.
if value <= 12 {
f64::from(value as i32) * 0.1 + 0.3
} else if value <= 25 {
f64::from((value - 12) as i32) * 0.5 + 1.5
} else if value <= 33 {
f64::from((value - 25) as i32) + 8.0
} else {
f64::from((value - 33) as i32) * 2.0 + 16.0
}
}
/// Rust port of PCL `SplitJavaArguments` (ModLaunch.vb at pinned commit
/// `639de1b48a44326cbd5465579295cecf23d9056a`): splits on spaces outside
/// double quotes, keeps the quote characters inside tokens (PCL re-quotes
/// whole arguments downstream), treats `\"` literally, and collapses line
/// breaks into separators.
fn pcl_split_arguments(input: &str) -> Vec<String> {
// Line endings become separators, collapsed like PCL's
// ReplaceLineEndings(" ", mergeMultiple:=True); empty tokens are dropped
// below, so consecutive separators behave identically.
let normalized: String = input
.chars()
.map(|character| {
if character == '\n' || character == '\r' {
' '
} else {
character
}
})
.collect();
let mut tokens = Vec::new();
let mut current = String::new();
let mut in_quotes = false;
let mut chars = normalized.chars().peekable();
while let Some(character) = chars.next() {
if character == '\\' && chars.peek() == Some(&'"') {
current.push_str("\\\"");
chars.next();
} else if character == '"' {
in_quotes = !in_quotes;
current.push('"');
} else if character == ' ' && !in_quotes {
if !current.is_empty() {
tokens.push(std::mem::take(&mut current));
}
} else {
current.push(character);
}
}
if !current.is_empty() {
tokens.push(current);
}
tokens
}
/// Reads HMCL's global game settings used when a version declares
/// `"usesGlobal": true`.
///
/// Upstream reference (pinned commit `083dbb18ade1c935e2e56d0bdefcd718be1e2ed6`):
/// `SettingsManager.GAME_SETTINGS_LOCATION` is `.hmcl/config/game-settings.json`,
/// a [`GameSettingsPresets`](source: GameSettingsPresets.java) document whose
/// `presets` array carries `GameSettings` properties (`jvmOptions`, `minMemory`,
/// `maxMemory`, ...); the preset an ordinary instance inherits is chosen by
/// `LauncherSettings.PROPERTY_DEFAULT_GAME_SETTINGS_PRESET`
/// (`defaultGameSettingsPreset`) inside `.hmcl/config/launcher-settings.json`.
/// When neither file exists the caller keeps the per-version values untouched.
pub(crate) struct HmclGlobalGameSettings {
pub jvm_options: Option<String>,
pub min_memory: Option<i64>,
pub max_memory: Option<i64>,
}
pub(crate) fn hmcl_global_game_settings(
launcher_root: &Path,
) -> Option<HmclGlobalGameSettings> {
let presets_raw = std::fs::read_to_string(
launcher_root
.join(".hmcl")
.join("config")
.join("game-settings.json"),
)
.ok()?;
let presets =
serde_json::from_str::<serde_json::Value>(&presets_raw).ok()?;
let presets = presets.get("presets")?.as_array()?;
let default_id = std::fs::read_to_string(
launcher_root
.join(".hmcl")
.join("config")
.join("launcher-settings.json"),
)
.ok()
.and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok())
.and_then(|settings| {
settings
.get("defaultGameSettingsPreset")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
});
let preset = presets
.iter()
.find(|preset| {
default_id.as_deref().is_some_and(|default_id| {
preset.get("id").and_then(serde_json::Value::as_str)
== Some(default_id)
})
})
.or_else(|| presets.first())?;
let number =
|key: &str| preset.get(key).and_then(serde_json::Value::as_i64);
Some(HmclGlobalGameSettings {
jvm_options: preset
.get("jvmOptions")
.and_then(serde_json::Value::as_str)
.map(str::to_string),
min_memory: number("minMemory"),
max_memory: number("maxMemory"),
})
}
/// Overlays HMCL's global game settings onto per-version settings for
/// versions with `usesGlobal: true`. Per-version values win; the global file
/// only fills fields the version left unset ("作回退").
pub(crate) fn hmcl_with_global_fallback(
settings: &HmclVersionSettings,
launcher_root: Option<&Path>,
) -> HmclVersionSettings {
let mut effective = settings.clone();
if effective.uses_global != Some(true) {
return effective;
}
let Some(launcher_root) = launcher_root else {
tracing::debug!(
"Linked HMCL version uses global settings but has no launcher \
root recorded; keeping per-version settings"
);
return effective;
};
let Some(global) = hmcl_global_game_settings(launcher_root) else {
tracing::debug!(
"Linked HMCL version uses global settings but {} could not be \
read; keeping per-version settings",
launcher_root
.join(".hmcl")
.join("config")
.join("game-settings.json")
.display()
);
return effective;
};
if effective.java_args.is_none() {
effective.java_args = global.jvm_options;
}
if effective.min_memory.is_none() {
effective.min_memory = global.min_memory;
}
if effective.max_memory.is_none() {
effective.max_memory = global.max_memory;
}
effective
}
/// PCL `McInstance.PathIndie` at commits
/// `639de1b48a44326cbd5465579295cecf23d9056a` and
/// `aa3b81c6afb3cd1896dda271578b002066512177`.
///
/// Older rows may not have `launcher_root`; when unavailable, the upstream
/// default `LaunchArgumentIndieV2 = 4` (isolate all) is used after the per-version
/// and content-presence fallbacks.
pub(crate) fn resolve_pcl_game_dir(
dot_minecraft: &Path,
version_dir: &Path,
dialect: LinkedLauncherDialect,
merged: &MergedVersion,
launcher_root: Option<&Path>,
) -> crate::Result<PathBuf> {
let version_dir = version_dir.to_path_buf();
let pcl_dir = version_dir.join("PCL");
let instance_values = match dialect {
LinkedLauncherDialect::Pcl => {
read_flat_config(&pcl_dir.join("Setup.ini"))?
}
LinkedLauncherDialect::PclCe => {
read_flat_config(&pcl_dir.join("config.v1.yml"))?
}
_ => return Ok(dot_minecraft.to_path_buf()),
};
if let Some(value) = config_bool(&instance_values, "VersionArgumentIndieV2")
{
return Ok(if value {
version_dir
} else {
dot_minecraft.to_path_buf()
});
}
if let Some(value) = config_i64(&instance_values, "VersionArgumentIndie")
&& value > 0
{
return Ok(if value == 1 {
version_dir
} else {
dot_minecraft.to_path_buf()
});
}
if directory_has_files(&version_dir.join("mods"))?
|| directory_has_directories(&version_dir.join("saves"))?
{
return Ok(version_dir);
}
let global_values = if let Some(launcher_root) = launcher_root {
match dialect {
LinkedLauncherDialect::Pcl => {
read_flat_config(&launcher_root.join("PCL").join("Setup.ini"))?
}
LinkedLauncherDialect::PclCe => read_flat_config(
&launcher_root.join("PCL").join("config.v1.yml"),
)?,
_ => Default::default(),
}
} else {
Default::default()
};
// TODO(direct-link): PCL/PCL-CE fullscreen and other unmapped per-version
// settings remain P1 follow-up; Java selection, RAM, and custom JVM/game
// arguments are consumed via pcl_launch_settings() at launch time.
let policy =
config_i64(&global_values, "LaunchArgumentIndieV2").unwrap_or(4);
let modded = pcl_is_modded(merged);
let release = matches!(merged.type_, None | Some(VersionType::Release));
let isolate = match policy {
0 => false,
1 => modded,
2 => !release,
3 => modded || !release,
_ => true,
};
Ok(if isolate {
version_dir
} else {
dot_minecraft.to_path_buf()
})
}
fn pcl_is_modded(merged: &MergedVersion) -> bool {
if merged.main_class.as_deref().is_some_and(|main| {
main != "net.minecraft.client.main.Main"
&& main != "net.minecraft.client.Minecraft"
}) {
return true;
}
merged
.libraries
.iter()
.any(|library| pcl_coordinate_is_modded_loader(&library.library.name))
}
/// Coordinate check shared by the isolation policy (`pcl_is_modded`) and the
/// RAM profile.
fn pcl_coordinate_is_modded_loader(name: &str) -> bool {
let mut coordinate = name.split(':');
let group = coordinate.next().unwrap_or_default().to_ascii_lowercase();
let artifact = coordinate.next().unwrap_or_default().to_ascii_lowercase();
matches!(
(group.as_str(), artifact.as_str()),
("net.fabricmc", "fabric-loader")
| ("org.quiltmc", "quilt-loader")
| ("net.minecraftforge", "forge" | "fmlloader")
| ("net.neoforged", "neoforge")
| ("net.neoforged.fancymodloader", "loader")
| ("com.cleanroommc", "cleanroom")
| ("com.mumfrey", "liteloader")
| ("optifine", "optifine")
)
}
/// Classifies a merged version for PCL's automatic RAM estimate
/// (pinned PCL-CE `GetRam`): `(installable-mods version, OptiFine version)`.
/// OptiFine-only versions use their own fixed targets upstream, so OptiFine
/// alone does not count as a mod loader here.
pub(crate) fn pcl_ram_profile(
libraries: &[daedalus::minecraft::Library],
) -> (bool, bool) {
let mut modable = false;
let mut opti_fine = false;
for library in libraries {
let mut coordinate = library.name.split(':');
let group = coordinate.next().unwrap_or_default().to_ascii_lowercase();
let artifact =
coordinate.next().unwrap_or_default().to_ascii_lowercase();
match (group.as_str(), artifact.as_str()) {
("optifine", "optifine") => opti_fine = true,
("net.fabricmc", "fabric-loader")
| ("org.quiltmc", "quilt-loader")
| ("net.minecraftforge", "forge" | "fmlloader")
| ("net.neoforged", "neoforge")
| ("net.neoforged.fancymodloader", "loader")
| ("com.cleanroommc", "cleanroom")
| ("com.mumfrey", "liteloader") => modable = true,
_ => {}
}
}
(modable, opti_fine)
}
/// Available physical memory in GiB, rounded to one decimal like PCL does
/// before running its allocation stages (pinned GetRam implementations).
pub(crate) fn pcl_available_memory_gb() -> f64 {
let mut system = sysinfo::System::new_with_specifics(
sysinfo::RefreshKind::nothing()
.with_memory(sysinfo::MemoryRefreshKind::nothing().with_ram()),
);
system.refresh_memory();
let available_gib =
system.available_memory() as f64 / 1024.0 / 1024.0 / 1024.0;
(available_gib * 10.0).round() / 10.0
}
fn read_flat_config(
path: &Path,
) -> crate::Result<std::collections::HashMap<String, String>> {
let content = match std::fs::read_to_string(path) {
Ok(content) => content,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(Default::default());
}
Err(error) => {
return Err(crate::ErrorKind::FSError(format!(
"Failed to read linked launcher config {}: {error}",
path.display()
))
.into());
}
};
let mut values = std::collections::HashMap::new();
for line in content.lines() {
let line = line.trim().trim_start_matches('\u{feff}');
if line.is_empty() || line.starts_with(['#', ';', '[']) {
continue;
}
// Prefer the separator that appears first: YAML configs (`config.v1.yml`)
// separate with ':', PCL's Setup.ini with '=', and INI values may
// themselves contain colons (e.g. JSON selections).
let pair = match (line.find(':'), line.find('=')) {
(Some(colon), Some(equals)) => {
if equals < colon {
line.split_once('=')
} else {
line.split_once(':')
}
}
(Some(_), None) => line.split_once(':'),
(None, Some(_)) => line.split_once('='),
(None, None) => None,
};
if let Some((key, value)) = pair {
values.insert(
key.trim().to_ascii_lowercase(),
value.trim().trim_matches(['\'', '"']).to_string(),
);
}
}
Ok(values)
}
fn config_bool(
values: &std::collections::HashMap<String, String>,
key: &str,
) -> Option<bool> {
match values
.get(&key.to_ascii_lowercase())?
.to_ascii_lowercase()
.as_str()
{
"true" | "1" => Some(true),
"false" | "0" => Some(false),
_ => None,
}
}
fn config_i64(
values: &std::collections::HashMap<String, String>,
key: &str,
) -> Option<i64> {
values.get(&key.to_ascii_lowercase())?.parse().ok()
}
fn config_string(
values: &std::collections::HashMap<String, String>,
key: &str,
) -> Option<String> {
values.get(&key.to_ascii_lowercase()).cloned()
}
fn directory_has_files(path: &Path) -> crate::Result<bool> {
directory_has(path, |entry| {
entry.file_type().is_ok_and(|kind| kind.is_file())
})
}
fn directory_has_directories(path: &Path) -> crate::Result<bool> {
directory_has(path, |entry| {
entry.file_type().is_ok_and(|kind| kind.is_dir())
})
}
fn directory_has(
path: &Path,
predicate: impl Fn(&std::fs::DirEntry) -> bool,
) -> crate::Result<bool> {
match std::fs::read_dir(path) {
Ok(entries) => Ok(entries
.filter_map(Result::ok)
.any(|entry| predicate(&entry))),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(crate::ErrorKind::FSError(format!(
"Failed to inspect linked directory {}: {error}",
path.display()
))
.into()),
}
}
pub(crate) fn extract_linked_natives(
direct: &DirectLinkedLaunch,
libraries: &[LinkedLibrary],
target: &Path,
java_arch: &str,
minecraft_updated: bool,
) -> crate::Result<()> {
std::fs::create_dir_all(target).map_err(|error| {
crate::ErrorKind::FSError(format!(
"Failed to create native cache {}: {error}",
target.display()
))
})?;
for library in libraries {
if let Some(rules) = library.library.rules.as_deref()
&& !super::parse_rules(
rules,
java_arch,
&crate::instance::QuickPlayType::None,
minecraft_updated,
)
{
continue;
}
let Some((classifier, download)) = native_download(library, java_arch)
else {
continue;
};
let archive = if library.hint.as_deref() == Some("local") {
direct.library_path(library)?
} else {
let relative = download
.and_then(|download| download.path.as_deref())
.map(PathBuf::from)
.unwrap_or(
classified_artifact_path(
&library.library.name,
&classifier,
)?
.into(),
);
direct.libraries_dir().join(relative)
};
let excludes = library
.library
.extract
.as_ref()
.and_then(|extract| extract.exclude.as_deref())
.unwrap_or(&[]);
extract_native_archive(&archive, target, excludes)?;
}
Ok(())
}
/// Whether a library declares only native classifier jars and no main
/// artifact. Vanilla publishes no plain jar for such libraries (e.g. the
/// 1.12.2 `net.java.jinput:jinput-platform:2.0.5`, which only ships
/// `natives-<os>` classifiers), so neither the vanilla launcher nor HMCL ever
/// downloads or class-paths one; the selected classifier is handled by native
/// extraction instead.
pub(crate) fn is_native_only_library(library: &Library) -> bool {
let downloads = library.downloads.as_ref();
downloads
.and_then(|downloads| downloads.artifact.as_ref())
.is_none()
&& (library.natives.is_some()
|| downloads
.and_then(|downloads| downloads.classifiers.as_ref())
.is_some_and(|classifiers| {
classifiers.keys().any(|key| key.starts_with("native"))
}))
}
pub(crate) fn native_download<'a>(
library: &'a LinkedLibrary,
java_arch: &str,
) -> Option<(String, Option<&'a LibraryDownload>)> {
let normalized_arch = normalize_architecture(java_arch);
let native_os = Os::native_arch(normalized_arch);
let base_os = native_os.get_os();
let classifiers = library
.library
.downloads
.as_ref()
.and_then(|downloads| downloads.classifiers.as_ref());
let classifier = if let Some(natives) = library.library.natives.as_ref() {
natives
.get(&native_os)
.or_else(|| natives.get(&base_os))?
.replace("${arch}", linked_architecture_width(java_arch))
} else {
let classifiers = classifiers?;
native_classifier_candidates(normalized_arch)
.into_iter()
.find(|candidate| classifiers.contains_key(candidate))?
};
let download =
classifiers.and_then(|classifiers| classifiers.get(&classifier));
Some((classifier, download))
}
fn native_classifier_candidates(java_arch: &str) -> Vec<String> {
let os = match std::env::consts::OS {
"macos" => "osx",
other => other,
};
let arch = java_arch.to_ascii_lowercase();
let width = linked_architecture_width(java_arch);
let mut candidates = Vec::new();
for key in ["", arch.as_str(), width] {
for variant in ["", "native", "natives"] {
let mut classifier = String::new();
if !variant.is_empty() {
classifier.push_str(variant);
classifier.push('-');
}
classifier.push_str(os);
if !key.is_empty() {
classifier.push('-');
classifier.push_str(key);
}
candidates.push(classifier);
}
}
candidates
}
pub(crate) fn classified_artifact_path(
name: &str,
classifier: &str,
) -> crate::Result<String> {
let path = daedalus::get_path_from_artifact(name)?;
if name.split(':').nth(3).is_some() {
return Ok(path);
}
Ok(if let Some((prefix, extension)) = path.rsplit_once('.') {
format!("{prefix}-{classifier}.{extension}")
} else {
format!("{path}-{classifier}")
})
}
fn maven_artifact_filename(name: &str) -> crate::Result<String> {
let path = daedalus::get_path_from_artifact(name)?;
Ok(Path::new(&path)
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| {
crate::ErrorKind::LauncherError(format!(
"Invalid local library coordinate: {name}"
))
})?
.to_string())
}
fn extract_native_archive(
archive_path: &Path,
target: &Path,
excludes: &[String],
) -> crate::Result<()> {
let file = std::fs::File::open(archive_path).map_err(|error| {
crate::ErrorKind::LauncherError(format!(
"Failed to open linked native archive {}: {error}",
archive_path.display()
))
})?;
let mut archive = zip::ZipArchive::new(file).map_err(|error| {
crate::ErrorKind::LauncherError(format!(
"Failed to read linked native archive {}: {error}",
archive_path.display()
))
})?;
for index in 0..archive.len() {
let mut entry = archive.by_index(index).map_err(|error| {
crate::ErrorKind::LauncherError(format!(
"Failed to read entry from linked native archive {}: {error}",
archive_path.display()
))
})?;
let Some(relative) = entry.enclosed_name() else {
return Err(crate::ErrorKind::LauncherError(format!(
"Unsafe path in linked native archive {}",
archive_path.display()
))
.into());
};
let normalized = relative.to_string_lossy().replace('\\', "/");
if excludes.iter().any(|exclude| {
let exclude = exclude.replace('\\', "/");
normalized.starts_with(exclude.trim_start_matches('/'))
}) {
continue;
}
if entry
.unix_mode()
.is_some_and(|mode| mode & 0o170000 == 0o120000)
{
return Err(crate::ErrorKind::LauncherError(format!(
"Symlink entry in linked native archive {}",
archive_path.display()
))
.into());
}
let destination = target.join(relative);
if entry.is_dir() {
std::fs::create_dir_all(&destination)?;
continue;
}
if let Some(parent) = destination.parent() {
std::fs::create_dir_all(parent)?;
}
let mut output = std::fs::File::create(&destination)?;
std::io::copy(&mut entry, &mut output)?;
output.flush()?;
}
Ok(())
}
fn normalize_architecture(java_arch: &str) -> &str {
if java_arch.eq_ignore_ascii_case("amd64") {
"x86_64"
} else if java_arch.eq_ignore_ascii_case("i386")
|| java_arch.eq_ignore_ascii_case("i686")
{
"x86"
} else if java_arch.eq_ignore_ascii_case("arm64") {
"aarch64"
} else if java_arch.eq_ignore_ascii_case("arm32") {
"arm"
} else {
java_arch
}
}
pub(crate) fn linked_architecture_width(java_arch: &str) -> &'static str {
match normalize_architecture(java_arch)
.to_ascii_lowercase()
.as_str()
{
"x86" | "arm" => "32",
"x86_64" | "aarch64" => "64",
_ => "64",
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn minimal_merged() -> MergedVersion {
let root = tempfile::tempdir().unwrap();
let version = root.path().join("versions/base");
std::fs::create_dir_all(&version).unwrap();
std::fs::write(
version.join("base.json"),
serde_json::to_vec(&json!({
"id":"base", "mainClass":"net.minecraft.client.main.Main",
"assetIndex":{"id":"1", "sha1":"", "size":0, "totalSize":0, "url":""}
})).unwrap(),
).unwrap();
super::super::local_version::load_version_chain(root.path(), "base")
.unwrap()
}
#[test]
fn parses_dialects_without_falling_back_to_hmcl() {
assert_eq!(
LinkedLauncherDialect::parse("hmcl").unwrap(),
LinkedLauncherDialect::Hmcl
);
assert_eq!(
LinkedLauncherDialect::parse("pcl2").unwrap(),
LinkedLauncherDialect::Pcl
);
assert_eq!(
LinkedLauncherDialect::parse("pcl2_ce").unwrap(),
LinkedLauncherDialect::PclCe
);
assert!(LinkedLauncherDialect::parse("unknown").is_err());
}
#[test]
fn legacy_external_version_override_uses_the_actual_manifest_stem() {
let root = tempfile::tempdir().unwrap();
let version_dir = root.path().join("versions").join("My Instance");
std::fs::create_dir_all(&version_dir).unwrap();
std::fs::write(
version_dir.join("1.20.1-fabric.json"),
serde_json::to_vec(&json!({
"id": "1.20.1-fabric",
"mainClass": "net.minecraft.client.main.Main"
}))
.unwrap(),
)
.unwrap();
let direct =
DirectLinkedLaunch::from_external_version_dir(&version_dir)
.unwrap()
.expect("a conventional versions directory is external");
assert_eq!(direct.version_id, "1.20.1-fabric");
assert_eq!(
direct.version_json,
Some(
crate::util::io::canonicalize(
version_dir.join("1.20.1-fabric.json"),
)
.unwrap(),
)
);
assert_eq!(
direct.dot_minecraft,
crate::util::io::canonicalize(root.path()).unwrap()
);
}
#[test]
fn pcl_instance_configs_select_exact_game_directory() {
let root = tempfile::tempdir().unwrap();
let version = root.path().join("versions/demo");
std::fs::create_dir_all(version.join("PCL")).unwrap();
std::fs::write(
version.join("PCL/Setup.ini"),
"VersionArgumentIndieV2: true\n",
)
.unwrap();
let isolated = resolve_pcl_game_dir(
root.path(),
&version,
LinkedLauncherDialect::Pcl,
&minimal_merged(),
None,
)
.unwrap();
assert_eq!(isolated, version);
std::fs::write(
version.join("PCL/config.v1.yml"),
"VersionArgumentIndieV2: false\n",
)
.unwrap();
let shared = resolve_pcl_game_dir(
root.path(),
&version,
LinkedLauncherDialect::PclCe,
&minimal_merged(),
None,
)
.unwrap();
assert_eq!(shared, root.path());
}
#[test]
fn generic_version_with_content_folders_uses_its_isolated_game_dir() {
let root = tempfile::tempdir().unwrap();
let version = root.path().join("versions/demo");
std::fs::create_dir_all(version.join("mods")).unwrap();
assert_eq!(
resolve_content_game_dir(root.path(), &version).unwrap(),
version
);
}
#[test]
fn generic_version_isolated_even_without_content_folders() {
let root = tempfile::tempdir().unwrap();
let version = root.path().join("versions/demo");
std::fs::create_dir_all(&version).unwrap();
std::fs::write(
version.join("demo.json"),
serde_json::to_vec(&json!({
"id": "demo",
"mainClass": "net.minecraft.client.main.Main"
}))
.unwrap(),
)
.unwrap();
assert_eq!(
resolve_content_game_dir(root.path(), &version).unwrap(),
root.path()
);
let direct = DirectLinkedLaunch {
dot_minecraft: root.path().to_path_buf(),
launcher_root: None,
version_id: "demo".to_string(),
version_json: Some(version.join("demo.json")),
dialect: LinkedLauncherDialect::Generic,
game_dir_mode: None,
};
assert_eq!(direct.resolve().unwrap().game_dir, version);
}
#[test]
fn explicit_external_root_mode_overrides_pcl_configuration() {
let root = tempfile::tempdir().unwrap();
let version = root.path().join("versions/demo");
std::fs::create_dir_all(version.join("PCL")).unwrap();
std::fs::write(
version.join("PCL/config.v1.yml"),
"VersionArgumentIndieV2: true\n",
)
.unwrap();
std::fs::write(
version.join("demo.json"),
serde_json::to_vec(&json!({
"id": "demo",
"mainClass": "net.minecraft.client.main.Main"
}))
.unwrap(),
)
.unwrap();
let shared = DirectLinkedLaunch {
dot_minecraft: root.path().to_path_buf(),
launcher_root: None,
version_id: "demo".to_string(),
version_json: Some(version.join("demo.json")),
dialect: LinkedLauncherDialect::PclCe,
game_dir_mode: Some(ExternalGameDirMode::Shared),
};
let isolated = DirectLinkedLaunch {
game_dir_mode: Some(ExternalGameDirMode::Isolated),
..shared.clone()
};
assert_eq!(shared.resolve().unwrap().game_dir, root.path());
assert_eq!(isolated.resolve().unwrap().game_dir, version);
}
#[test]
fn hmcl_explicitly_configured_empty_version_is_isolated() {
let launcher = tempfile::tempdir().unwrap();
let dot_minecraft = launcher.path().join("game");
let version = dot_minecraft.join("versions").join("demo");
std::fs::create_dir_all(&version).unwrap();
let hmcl_dir = launcher.path().join(".hmcl");
std::fs::create_dir_all(&hmcl_dir).unwrap();
std::fs::write(
hmcl_dir.join("hmcl.json"),
serde_json::to_vec(&serde_json::json!({
"configurations": {
"demo": { "gameDir": version.to_string_lossy() }
}
}))
.unwrap(),
)
.unwrap();
assert_eq!(
resolve_hmcl_game_dir(
Some(launcher.path()),
&dot_minecraft,
&version
)
.unwrap(),
version
);
}
#[test]
fn pcl_fallbacks_apply_before_global_isolation_policy() {
let root = tempfile::tempdir().unwrap();
let launcher = tempfile::tempdir().unwrap();
let version = root.path().join("versions/demo");
std::fs::create_dir_all(&version).unwrap();
std::fs::create_dir_all(launcher.path().join("PCL")).unwrap();
std::fs::write(
launcher.path().join("PCL/Setup.ini"),
"LaunchArgumentIndieV2=0\n",
)
.unwrap();
let shared = resolve_pcl_game_dir(
root.path(),
&version,
LinkedLauncherDialect::Pcl,
&minimal_merged(),
Some(launcher.path()),
)
.unwrap();
assert_eq!(shared, root.path());
std::fs::create_dir_all(version.join("mods")).unwrap();
std::fs::write(version.join("mods/example.jar"), b"mod").unwrap();
let isolated = resolve_pcl_game_dir(
root.path(),
&version,
LinkedLauncherDialect::Pcl,
&minimal_merged(),
Some(launcher.path()),
)
.unwrap();
assert_eq!(isolated, version);
}
#[test]
fn pcl_modded_policy_does_not_treat_vanilla_libraries_as_a_loader() {
let root = tempfile::tempdir().unwrap();
let launcher = tempfile::tempdir().unwrap();
let version = root.path().join("versions/demo");
std::fs::create_dir_all(&version).unwrap();
std::fs::create_dir_all(launcher.path().join("PCL")).unwrap();
std::fs::write(
launcher.path().join("PCL/Setup.ini"),
"LaunchArgumentIndieV2=1\n",
)
.unwrap();
let mut merged = minimal_merged();
merged.libraries.push(
serde_json::from_value(json!({"name":"org.lwjgl:lwjgl:3.3.3"}))
.unwrap(),
);
let shared = resolve_pcl_game_dir(
root.path(),
&version,
LinkedLauncherDialect::Pcl,
&merged,
Some(launcher.path()),
)
.unwrap();
assert_eq!(shared, root.path());
}
#[test]
fn explicit_version_json_controls_the_version_directory() {
let root = tempfile::tempdir().unwrap();
let version_json = root.path().join("versions/folder/manifest.json");
std::fs::create_dir_all(version_json.parent().unwrap()).unwrap();
let direct = DirectLinkedLaunch {
dot_minecraft: root.path().to_path_buf(),
launcher_root: None,
version_id: "logical-id".to_string(),
version_json: Some(version_json),
dialect: LinkedLauncherDialect::Hmcl,
game_dir_mode: None,
};
assert_eq!(direct.version_dir(), root.path().join("versions/folder"));
assert_eq!(
direct.client_jar("logical-id"),
root.path().join("versions/folder/logical-id.jar")
);
std::fs::write(root.path().join("versions/folder/folder.jar"), b"jar")
.unwrap();
assert_eq!(
direct.client_jar("logical-id"),
root.path().join("versions/folder/folder.jar")
);
}
#[test]
fn native_extraction_respects_excludes() {
let root = tempfile::tempdir().unwrap();
let libraries = root.path().join("libraries/x/native/1");
std::fs::create_dir_all(&libraries).unwrap();
let archive_path = libraries.join("native-1-natives-linux.jar");
let file = std::fs::File::create(&archive_path).unwrap();
let mut zip = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default();
zip.start_file("libnative.so", options).unwrap();
zip.write_all(b"native").unwrap();
zip.start_file("META-INF/manifest", options).unwrap();
zip.write_all(b"excluded").unwrap();
zip.finish().unwrap();
let os = serde_json::to_value(Os::native().get_os()).unwrap();
let mut natives = serde_json::Map::new();
natives.insert(os.as_str().unwrap().to_string(), json!("natives-test"));
let library: LinkedLibrary = serde_json::from_value(json!({
"name":"x:native:1",
"natives":natives,
"extract":{"exclude":["META-INF/"]},
"downloads":{"classifiers":{"natives-test":{"path":"x/native/1/native-1-natives-linux.jar", "sha1":"", "size":0, "url":""}}}
})).unwrap();
let direct = DirectLinkedLaunch {
dot_minecraft: root.path().to_path_buf(),
launcher_root: None,
version_id: "demo".to_string(),
version_json: None,
dialect: LinkedLauncherDialect::Hmcl,
game_dir_mode: None,
};
let target = root.path().join("axolotl-cache");
extract_linked_natives(
&direct,
&[library],
&target,
std::env::consts::ARCH,
true,
)
.unwrap();
assert_eq!(
std::fs::read(target.join("libnative.so")).unwrap(),
b"native"
);
assert!(!target.join("META-INF/manifest").exists());
}
#[test]
fn legacy_native_without_download_metadata_uses_maven_classifier_path() {
let root = tempfile::tempdir().unwrap();
let classifier = "natives-test";
let relative =
classified_artifact_path("x:native:1", classifier).unwrap();
let archive_path = root.path().join("libraries").join(relative);
std::fs::create_dir_all(archive_path.parent().unwrap()).unwrap();
let file = std::fs::File::create(&archive_path).unwrap();
let mut zip = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default();
zip.start_file("legacy-native.bin", options).unwrap();
zip.write_all(b"legacy").unwrap();
zip.finish().unwrap();
let os = serde_json::to_value(Os::native().get_os()).unwrap();
let mut natives = serde_json::Map::new();
natives.insert(os.as_str().unwrap().to_string(), json!(classifier));
let library: LinkedLibrary = serde_json::from_value(json!({
"name":"x:native:1",
"natives":natives
}))
.unwrap();
let direct = DirectLinkedLaunch {
dot_minecraft: root.path().to_path_buf(),
launcher_root: None,
version_id: "demo".to_string(),
version_json: None,
dialect: LinkedLauncherDialect::Hmcl,
game_dir_mode: None,
};
let target = root.path().join("axolotl-cache");
extract_linked_natives(
&direct,
&[library],
&target,
std::env::consts::ARCH,
true,
)
.unwrap();
assert_eq!(
std::fs::read(target.join("legacy-native.bin")).unwrap(),
b"legacy"
);
}
#[test]
fn native_archive_rejects_parent_directory_entries() {
let root = tempfile::tempdir().unwrap();
let archive_path = root.path().join("unsafe.jar");
let file = std::fs::File::create(&archive_path).unwrap();
let mut zip = zip::ZipWriter::new(file);
zip.start_file(
"../outside.bin",
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(b"unsafe").unwrap();
zip.finish().unwrap();
let target = root.path().join("target");
assert!(extract_native_archive(&archive_path, &target, &[]).is_err());
assert!(!root.path().join("outside.bin").exists());
}
#[test]
fn hmcl_java_args_keep_quoted_arguments_together() {
let mut args = Vec::new();
apply_hmcl_settings(
&HmclVersionSettings {
java_args: Some(
"-Dlog4j.configurationFile=\"C:/my dir/log.xml\" -Xmx2G"
.to_string(),
),
..Default::default()
},
&mut MemorySettings {
maximum: 1024,
automatic: false,
optimize_before_launch: false,
},
&mut WindowSize(856, 481),
&mut args,
Some(17),
);
assert_eq!(
args,
["-Dlog4j.configurationFile=C:/my dir/log.xml", "-Xmx2G"]
);
let single_quoted = hmcl_tokenize("'a b' c");
assert_eq!(single_quoted, ["a b", "c"]);
// Backslashes are literal in HMCL's tokenizer: an unquoted argument
// containing spaces still splits, so users must quote it.
assert_eq!(
hmcl_tokenize("-Dx=C:\\path with space"),
["-Dx=C:\\path", "with", "space"]
);
assert_eq!(
hmcl_tokenize("\"-Dx=C:\\path with space\""),
["-Dx=C:\\path with space"]
);
assert_eq!(hmcl_tokenize("-Dy=\"unterminated"), ["-Dy=unterminated"]);
}
#[test]
fn hmcl_perm_size_never_reaches_modern_jvms() {
let mut base_memory = MemorySettings {
maximum: 1024,
automatic: false,
optimize_before_launch: false,
};
let mut resolution = WindowSize(856, 481);
let legacy_args = {
let mut args = Vec::new();
apply_hmcl_settings(
&HmclVersionSettings {
min_memory: Some(512),
perm_size: Some("256m".to_string()),
..Default::default()
},
&mut base_memory,
&mut resolution,
&mut args,
Some(7),
);
args
};
assert!(legacy_args.contains(&"-XX:PermSize=256m".to_string()));
assert!(legacy_args.contains(&"-Xms512M".to_string()));
for java_major in [Some(8), Some(17), None] {
let mut args = Vec::new();
apply_hmcl_settings(
&HmclVersionSettings {
perm_size: Some("256m".to_string()),
..Default::default()
},
&mut base_memory,
&mut resolution,
&mut args,
java_major,
);
assert!(!args.iter().any(|arg| arg.starts_with("-XX:MaxPermSize")));
assert_eq!(args, ["-XX:MetaspaceSize=256m"]);
}
let mut empty_args = Vec::new();
apply_hmcl_settings(
&HmclVersionSettings {
perm_size: Some(" ".to_string()),
..Default::default()
},
&mut base_memory,
&mut resolution,
&mut empty_args,
None,
);
assert!(empty_args.is_empty());
}
// -----------------------------------------------------------------------
// P1: PCL/PCL-CE instance-level Java, memory, and custom arguments
// -----------------------------------------------------------------------
fn pcl_direct(
dot_minecraft: &Path,
launcher_root: Option<&Path>,
version_id: &str,
dialect: LinkedLauncherDialect,
) -> DirectLinkedLaunch {
DirectLinkedLaunch {
dot_minecraft: dot_minecraft.to_path_buf(),
launcher_root: launcher_root.map(Path::to_path_buf),
version_id: version_id.to_string(),
version_json: None,
dialect,
game_dir_mode: None,
}
}
fn write_version_config(
root: &Path,
version_id: &str,
dialect: LinkedLauncherDialect,
content: &str,
) -> PathBuf {
let version = root.join("versions").join(version_id);
std::fs::create_dir_all(version.join("PCL")).unwrap();
let file = match dialect {
LinkedLauncherDialect::Pcl => "PCL/Setup.ini",
_ => "PCL/config.v1.yml",
};
std::fs::write(version.join(file), content).unwrap();
version
}
fn write_global_config(
launcher_root: &Path,
dialect: LinkedLauncherDialect,
content: &str,
) {
std::fs::create_dir_all(launcher_root.join("PCL")).unwrap();
let file = match dialect {
LinkedLauncherDialect::Pcl => "Setup.ini",
_ => "config.v1.yml",
};
std::fs::write(launcher_root.join("PCL").join(file), content).unwrap();
}
#[test]
fn pcl_ce_relative_java_resolves_against_launcher_root() {
let mc = tempfile::tempdir().unwrap();
let launcher = tempfile::tempdir().unwrap();
write_version_config(
mc.path(),
"demo",
LinkedLauncherDialect::PclCe,
"VersionArgumentJavaSelect: '{\"kind\":\"relative\",\
\"RelativePath\":\"runtime/jdk17\"}'\n",
);
write_global_config(
launcher.path(),
LinkedLauncherDialect::PclCe,
"LaunchArgumentJavaSelect: /opt/global/java\n",
);
let direct = pcl_direct(
mc.path(),
Some(launcher.path()),
"demo",
LinkedLauncherDialect::PclCe,
);
let settings = direct.pcl_launch_settings();
let candidates = settings.java_candidates(
&direct.version_dir(),
direct.launcher_root.as_deref(),
);
assert_eq!(
candidates,
vec![
launcher
.path()
.join("runtime/jdk17")
.join("bin")
.join(PCL_JAVA_BIN)
]
);
// A relative path escaping the launcher directory degrades to the
// global preference (pinned ModJava.cs containment check).
write_version_config(
mc.path(),
"demo",
LinkedLauncherDialect::PclCe,
"VersionArgumentJavaSelect: '{\"kind\":\"relative\",\
\"RelativePath\":\"../../outside\"}'\n",
);
let settings = direct.pcl_launch_settings();
let candidates = settings.java_candidates(
&direct.version_dir(),
direct.launcher_root.as_deref(),
);
assert_eq!(candidates, vec![PathBuf::from("/opt/global/java")]);
}
#[test]
fn pcl_ce_exist_and_global_preferences_order_candidates() {
let mc = tempfile::tempdir().unwrap();
let launcher = tempfile::tempdir().unwrap();
// {"kind":"exist"} selects the exact executable; no global fallback.
write_version_config(
mc.path(),
"demo",
LinkedLauncherDialect::PclCe,
r#"VersionArgumentJavaSelect: '{"kind":"exist","JavaExePath":"/usr/lib/jvm/17/bin/java"}'"#,
);
write_global_config(
launcher.path(),
LinkedLauncherDialect::PclCe,
"LaunchArgumentJavaSelect: /opt/global/java\n",
);
let direct = pcl_direct(
mc.path(),
Some(launcher.path()),
"demo",
LinkedLauncherDialect::PclCe,
);
assert_eq!(
direct.pcl_launch_settings().java_candidates(
&direct.version_dir(),
direct.launcher_root.as_deref(),
),
vec![PathBuf::from("/usr/lib/jvm/17/bin/java")]
);
// 使用全局设置 (and an empty value) falls back to the global entry.
for value in ["使用全局设置", ""] {
write_version_config(
mc.path(),
"demo",
LinkedLauncherDialect::PclCe,
&format!("VersionArgumentJavaSelect: {value}\n"),
);
assert_eq!(
direct.pcl_launch_settings().java_candidates(
&direct.version_dir(),
direct.launcher_root.as_deref(),
),
vec![PathBuf::from("/opt/global/java")]
);
}
}
#[test]
fn pcl_classic_v2_selects_forced_and_bundled_java() {
let mc = tempfile::tempdir().unwrap();
let launcher = tempfile::tempdir().unwrap();
let direct = pcl_direct(
mc.path(),
Some(launcher.path()),
"demo",
LinkedLauncherDialect::Pcl,
);
// V2=3 with the legacy JSON selection.
write_version_config(
mc.path(),
"demo",
LinkedLauncherDialect::Pcl,
"VersionArgumentJavaV2=3\n\
VersionArgumentJavaSelect={\"Path\":\"C:/Program Files/Java/17\"}\n",
);
assert_eq!(
direct.pcl_launch_settings().java_candidates(
&direct.version_dir(),
direct.launcher_root.as_deref(),
),
vec![PathBuf::from("C:/Program Files/Java/17")]
);
// V2=2 searches the version folder for a bundled runtime.
let bundled = direct.version_dir().join("runtime/jdk-17").join("bin");
std::fs::create_dir_all(&bundled).unwrap();
std::fs::write(bundled.join(PCL_JAVA_BIN), b"").unwrap();
write_version_config(
mc.path(),
"demo",
LinkedLauncherDialect::Pcl,
"VersionArgumentJavaV2=2\n",
);
assert_eq!(
direct.pcl_launch_settings().java_candidates(
&direct.version_dir(),
direct.launcher_root.as_deref(),
),
vec![bundled.join(PCL_JAVA_BIN)]
);
// V2=0 keeps only the globally selected Java as the candidate.
write_global_config(
launcher.path(),
LinkedLauncherDialect::Pcl,
"LaunchArgumentJavaSelect=/opt/global/java\n",
);
write_version_config(
mc.path(),
"demo",
LinkedLauncherDialect::Pcl,
"VersionArgumentJavaV2=0\n",
);
assert_eq!(
direct.pcl_launch_settings().java_candidates(
&direct.version_dir(),
direct.launcher_root.as_deref(),
),
vec![PathBuf::from("/opt/global/java")]
);
}
#[test]
fn pcl_auto_ram_formula_matches_upstream_tables() {
// Vanilla targets 0.5/1.5/2.5/4 GB exhaust all four stages on a large
// machine: 8.0 GB total.
assert_eq!(pcl_auto_ram_gb(100.0, 0, false, false), 8.0);
// OptiFine-only versions use the fixed 0.5/1.5/3/5 table: 10.0 GB.
assert_eq!(pcl_auto_ram_gb(100.0, 0, false, true), 10.0);
// Modded targets grow with the mod count (50 mods → 13.0 GB).
assert_eq!(pcl_auto_ram_gb(100.0, 50, true, false), 13.0);
// Scarce memory caps the estimate at what is available.
assert_eq!(pcl_auto_ram_gb(0.4, 0, false, false), 0.5);
// Slider mapping (VersionRamCustom/LaunchRamCustom bands); compare
// with an epsilon because the first band multiplies by 0.1.
let slider = |value: i64| (pcl_slider_ram_gb(value) * 10.0).round();
assert_eq!(slider(12), 15.0);
assert_eq!(slider(25), 80.0);
assert_eq!(slider(33), 160.0);
assert_eq!(slider(40), 300.0);
}
#[test]
fn pcl_ram_modes_resolve_through_instance_and_global_files() {
let mc = tempfile::tempdir().unwrap();
let launcher = tempfile::tempdir().unwrap();
let direct = pcl_direct(
mc.path(),
Some(launcher.path()),
"demo",
LinkedLauncherDialect::PclCe,
);
// Manual instance value maps through the slider formula to MiB.
write_version_config(
mc.path(),
"demo",
LinkedLauncherDialect::PclCe,
"VersionRamType: 1\nVersionRamCustom: 20\n",
);
assert_eq!(
direct.pcl_launch_settings().resolve_ram_mb(
mc.path(),
false,
false,
100.0,
false
),
Some((5.5f64 * 1024.0).floor() as u32)
);
// A 32-bit JVM caps the allocation at 1 GiB (pinned PCL-CE GetRam).
write_version_config(
mc.path(),
"demo",
LinkedLauncherDialect::PclCe,
"VersionRamType: 1\nVersionRamCustom: 33\n",
);
assert_eq!(
direct.pcl_launch_settings().resolve_ram_mb(
mc.path(),
false,
false,
100.0,
true
),
Some(1024)
);
// 跟随全局 (the default) reads the launcher-global keys.
write_version_config(
mc.path(),
"demo",
LinkedLauncherDialect::PclCe,
"",
);
write_global_config(
launcher.path(),
LinkedLauncherDialect::PclCe,
"LaunchRamType: 1\nLaunchRamCustom: 12\n",
);
assert_eq!(
direct.pcl_launch_settings().resolve_ram_mb(
mc.path(),
false,
false,
100.0,
false
),
Some(1536)
);
// Without any global configuration Axolotl keeps its own setting.
let orphan =
pcl_direct(mc.path(), None, "demo", LinkedLauncherDialect::PclCe);
assert_eq!(
orphan.pcl_launch_settings().resolve_ram_mb(
mc.path(),
false,
false,
100.0,
false
),
None
);
// Automatic estimation counts files in the resolved game directory's
// mods folder (150 mods → 21.0 GB).
let mods = mc.path().join("mods");
std::fs::create_dir_all(&mods).unwrap();
for index in 0..150 {
std::fs::write(mods.join(format!("mod{index}.jar")), b"").unwrap();
}
write_global_config(
launcher.path(),
LinkedLauncherDialect::PclCe,
"LaunchRamType: 0\n",
);
assert_eq!(
direct.pcl_launch_settings().resolve_ram_mb(
mc.path(),
true,
false,
100.0,
false
),
Some((21.0f64 * 1024.0).floor() as u32)
);
}
#[test]
fn pcl_custom_jvm_and_game_arguments_are_tokenized_with_global_fallback() {
let mc = tempfile::tempdir().unwrap();
let launcher = tempfile::tempdir().unwrap();
let direct = pcl_direct(
mc.path(),
Some(launcher.path()),
"demo",
LinkedLauncherDialect::Pcl,
);
write_version_config(
mc.path(),
"demo",
LinkedLauncherDialect::Pcl,
"VersionAdvanceJvm=-Xmx2G -XX:+UseZGC\n\
VersionAdvanceGame=--tweakClass optifine.OptiFineTweaker\n",
);
let settings = direct.pcl_launch_settings();
assert_eq!(
settings.extra_jvm_args(),
["-Xmx2G".to_string(), "-XX:+UseZGC".to_string()]
);
assert_eq!(
settings.extra_game_args(),
[
"--tweakClass".to_string(),
"optifine.OptiFineTweaker".to_string()
]
);
// An empty instance value falls back to the global key (ModLaunch.vb:
// `If CustomArg = "" Then CustomArg = LaunchAdvanceJvm`); absent keys
// inject nothing.
write_version_config(
mc.path(),
"demo",
LinkedLauncherDialect::Pcl,
"VersionAdvanceJvm=\n",
);
write_global_config(
launcher.path(),
LinkedLauncherDialect::Pcl,
"LaunchAdvanceJvm=-Dglobal=1\n",
);
assert_eq!(
direct.pcl_launch_settings().extra_jvm_args(),
["-Dglobal=1".to_string()]
);
assert!(direct.pcl_launch_settings().extra_game_args().is_empty());
}
#[test]
fn pcl_split_arguments_keeps_quoted_tokens_intact() {
assert_eq!(
pcl_split_arguments("-Xmx2G -XX:+UseZGC"),
["-Xmx2G", "-XX:+UseZGC"]
);
assert_eq!(
pcl_split_arguments("-Dlog4j.configurationFile=\"my dir/log.xml\""),
["-Dlog4j.configurationFile=\"my dir/log.xml\""]
);
// An escaped quote does not toggle quote state (SplitJavaArguments).
assert_eq!(pcl_split_arguments("a\\\"b c"), ["a\\\"b", "c"]);
assert_eq!(pcl_split_arguments("\n-Xa \r\n-Xb "), ["-Xa", "-Xb"]);
}
#[test]
fn hmcl_uses_global_falls_back_to_launcher_settings_files() {
let launcher = tempfile::tempdir().unwrap();
let config_dir = launcher.path().join(".hmcl/config");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("game-settings.json"),
r#"{
"schema": {"id": "game-settings", "version": {"major": 1, "minor": 0, "patch": 0}},
"presets": [
{"id": "game-settings-preset:a", "jvmOptions": "-Xmx8G"},
{"id": "game-settings-preset:b", "jvmOptions": "-Xmx1G", "minMemory": 512, "maxMemory": 1024}
]
}"#,
)
.unwrap();
std::fs::write(
config_dir.join("launcher-settings.json"),
r#"{"defaultGameSettingsPreset": "game-settings-preset:b"}"#,
)
.unwrap();
// Per-version values win; unset fields fall back to the default preset.
let mut java_args = Vec::new();
let mut memory = MemorySettings {
maximum: 2048,
automatic: false,
optimize_before_launch: false,
};
let effective = hmcl_with_global_fallback(
&HmclVersionSettings {
uses_global: Some(true),
java_args: Some("-XX:PerVersion".to_string()),
..Default::default()
},
Some(launcher.path()),
);
apply_hmcl_settings(
&effective,
&mut memory,
&mut WindowSize(854, 480),
&mut java_args,
Some(17),
);
assert!(java_args.contains(&"-XX:PerVersion".to_string()));
assert!(!java_args.contains(&"-Xmx1G".to_string()));
assert!(java_args.contains(&"-Xms512M".to_string()));
assert_eq!(memory.maximum, 1024);
// Without usesGlobal the file is never consulted.
let untouched = hmcl_with_global_fallback(
&HmclVersionSettings::default(),
Some(launcher.path()),
);
assert_eq!(untouched.min_memory, None);
assert_eq!(untouched.max_memory, None);
// A missing global file keeps the per-version values untouched.
let missing = hmcl_with_global_fallback(
&HmclVersionSettings {
uses_global: Some(true),
..Default::default()
},
Some(tempfile::tempdir().unwrap().path()),
);
assert_eq!(missing.java_args, None);
}
#[test]
fn hmcl_server_ip_maps_to_legacy_server_game_arguments() {
let mut java_args = Vec::new();
let game_args = apply_hmcl_settings(
&HmclVersionSettings {
server_ip: Some("mc.example.com".to_string()),
server_port: None,
..Default::default()
},
&mut MemorySettings {
maximum: 1024,
automatic: false,
optimize_before_launch: false,
},
&mut WindowSize(854, 480),
&mut java_args,
Some(17),
);
// DefaultLauncher emits --server/--port for versions without Quick
// Play and defaults the port to 25565.
assert_eq!(
game_args,
["--server", "mc.example.com", "--port", "25565"]
);
let mut java_args = Vec::new();
let game_args = apply_hmcl_settings(
&HmclVersionSettings {
server_ip: Some("mc.example.com".to_string()),
server_port: Some(25566),
..Default::default()
},
&mut MemorySettings {
maximum: 1024,
automatic: false,
optimize_before_launch: false,
},
&mut WindowSize(854, 480),
&mut java_args,
Some(17),
);
assert_eq!(
game_args,
["--server", "mc.example.com", "--port", "25566"]
);
let mut java_args = Vec::new();
let game_args = apply_hmcl_settings(
&HmclVersionSettings {
server_ip: Some(" ".to_string()),
..Default::default()
},
&mut MemorySettings {
maximum: 1024,
automatic: false,
optimize_before_launch: false,
},
&mut WindowSize(854, 480),
&mut java_args,
Some(17),
);
assert!(game_args.is_empty());
assert!(java_args.is_empty());
}
#[test]
fn hmcl_perm_size_numbers_gain_unit_and_memory_bounds_are_clamped() {
let mut java_args = Vec::new();
let mut memory = MemorySettings {
maximum: 4096,
automatic: false,
optimize_before_launch: false,
};
apply_hmcl_settings(
&HmclVersionSettings {
max_memory: Some(1024),
min_memory: Some(4096),
perm_size: Some("256".to_string()),
..Default::default()
},
&mut memory,
&mut WindowSize(854, 480),
&mut java_args,
Some(17),
);
// Purely numeric permSize gains HMCL's lowercase unit...
assert!(java_args.contains(&"-XX:MetaspaceSize=256m".to_string()));
// ...and min memory is clamped down to the configured maximum.
assert_eq!(memory.maximum, 1024);
assert!(java_args.contains(&"-Xms1024M".to_string()));
assert!(!java_args.contains(&"-Xms4096M".to_string()));
// Values that already carry a unit pass through unchanged.
let mut java_args = Vec::new();
apply_hmcl_settings(
&HmclVersionSettings {
perm_size: Some("512M".to_string()),
..Default::default()
},
&mut memory,
&mut WindowSize(854, 480),
&mut java_args,
Some(17),
);
assert_eq!(java_args, ["-XX:MetaspaceSize=512M"]);
}
fn resolve_linked_fixture(
root: &Path,
version_id: &str,
libraries: serde_json::Value,
) -> MergedVersion {
let directory = root.join("versions").join(version_id);
std::fs::create_dir_all(&directory).unwrap();
std::fs::write(
directory.join(format!("{version_id}.json")),
serde_json::to_vec(
&json!({ "id": version_id, "libraries": libraries }),
)
.unwrap(),
)
.unwrap();
DirectLinkedLaunch {
dot_minecraft: root.to_path_buf(),
launcher_root: None,
version_id: version_id.to_string(),
version_json: None,
dialect: LinkedLauncherDialect::Generic,
game_dir_mode: None,
}
.resolve()
.unwrap()
.merged
}
fn cleanroom_mixed_fixture() -> serde_json::Value {
// The reported 1.12.2 + Cleanroom 0.6.11-alpha direct-link failure:
// the vanilla parent contributes the LWJGL 2 family (`lwjgl`,
// `lwjgl_util`, and the native carrier `lwjgl-platform`) while
// Cleanroom contributes the LWJGL 3 line; no LWJGL 2 jar may ever
// share a launch with the LWJGL 3 files. The vanilla JNA platform
// and Mojang ICU bundle are removed by the pre-existing Axolotl
// Cleanroom rule as well.
json!([
{"name": "com.cleanroommc:cleanroom:0.6.11-alpha"},
{"name": "org.lwjgl.lwjgl:lwjgl:2.9.4-nightly-20150209"},
{"name": "org.lwjgl.lwjgl:lwjgl_util:2.9.4-nightly-20150209"},
{"name": "org.lwjgl.lwjgl:lwjgl-platform:2.9.4-nightly-20150209", "natives": {"windows": "natives-windows"}},
{"name": "org.lwjgl:lwjgl:3.4.1-unsafe"},
{"name": "org.lwjgl:lwjgl:3.4.1-unsafe", "natives": {"windows": "natives-windows"}},
{"name": "com.cleanroommc:lwjglxx:1.1.22"},
{"name": "net.java.dev.jna:jna:5.19.1"},
{"name": "net.java.dev.jna:platform:3.4.0"},
{"name": "com.ibm.icu:icu4j-core-mojang:51.2"}
])
}
#[test]
fn hmcl_child_before_parent_merge_and_cleanroom_normalization_drop_lwjgl2_family()
{
// Realistic HMCL 1.12.2 + Cleanroom layout: the Cleanroom version
// document inherits the vanilla `1.12.2` document. The merger puts
// the child (loader) libraries before the parent (vanilla) ones, so
// without normalization the vanilla LWJGL 2 family would sit after
// the Cleanroom LWJGL 3 line.
let root = tempfile::tempdir().unwrap();
let vanilla_dir = root.path().join("versions/1.12.2");
std::fs::create_dir_all(&vanilla_dir).unwrap();
std::fs::write(
vanilla_dir.join("1.12.2.json"),
serde_json::to_vec(&json!({
"id": "1.12.2",
"mainClass": "net.minecraft.client.main.Main",
"libraries": [
{"name": "org.lwjgl.lwjgl:lwjgl:2.9.4-nightly-20150209"},
{"name": "org.lwjgl.lwjgl:lwjgl_util:2.9.4-nightly-20150209"},
{"name": "org.lwjgl.lwjgl:lwjgl-platform:2.9.4-nightly-20150209", "natives": {"windows": "natives-windows"}},
{"name": "net.java.dev.jna:platform:3.4.0"},
{"name": "com.ibm.icu:icu4j-core-mojang:51.2"}
]
}))
.unwrap(),
)
.unwrap();
let cleanroom_dir =
root.path().join("versions/1.12.2-Cleanroom-0.6.11-alpha");
std::fs::create_dir_all(&cleanroom_dir).unwrap();
std::fs::write(
cleanroom_dir.join("1.12.2-Cleanroom-0.6.11-alpha.json"),
serde_json::to_vec(&json!({
"id": "1.12.2-Cleanroom-0.6.11-alpha",
"inheritsFrom": "1.12.2",
"mainClass": "net.minecraft.launchwrapper.Launch",
"libraries": [
{"name": "com.cleanroommc:cleanroom:0.6.11-alpha"},
{"name": "org.lwjgl:lwjgl:3.4.1-unsafe"},
{"name": "org.lwjgl:lwjgl:3.4.1-unsafe", "natives": {"windows": "natives-windows"}},
{"name": "com.cleanroommc:lwjglxx:1.1.22"},
{"name": "net.java.dev.jna:jna:5.19.1"}
]
}))
.unwrap(),
)
.unwrap();
let resolved = DirectLinkedLaunch {
dot_minecraft: root.path().to_path_buf(),
launcher_root: None,
version_id: "1.12.2-Cleanroom-0.6.11-alpha".to_string(),
version_json: None,
dialect: LinkedLauncherDialect::Hmcl,
game_dir_mode: None,
}
.resolve()
.unwrap();
let mut merged = resolved.merged;
let names = merged
.libraries
.iter()
.map(|library| library.library.name.clone())
.collect::<Vec<_>>();
let lwjgl3_pos = names
.iter()
.position(|name| name == "org.lwjgl:lwjgl:3.4.1-unsafe")
.expect("Cleanroom LWJGL 3 line merged");
let lwjgl2_pos = names
.iter()
.position(|name| {
name == "org.lwjgl.lwjgl:lwjgl:2.9.4-nightly-20150209"
})
.expect("vanilla LWJGL 2 binding merged");
let platform_pos = names
.iter()
.position(|name| {
name == "org.lwjgl.lwjgl:lwjgl-platform:2.9.4-nightly-20150209"
})
.expect("vanilla LWJGL 2 native carrier merged");
// Child-first/parent-last: the LWJGL 2 family sits after the
// Cleanroom LWJGL 3 natives, which is exactly the shadowing order
// the normalization below must remove.
assert!(
lwjgl3_pos < lwjgl2_pos && lwjgl2_pos < platform_pos,
"unexpected merge order: {names:?}"
);
assert_eq!(
names
.iter()
.filter(|name| { *name == "org.lwjgl:lwjgl:3.4.1-unsafe" })
.count(),
2,
"HMCL keeps the LWJGL 3 main jar and its native classifier"
);
let removed = normalize_merged_loader_libraries(
ModLoader::Cleanroom,
&mut merged,
);
assert_eq!(
removed,
[
"org.lwjgl.lwjgl:lwjgl:2.9.4-nightly-20150209",
"org.lwjgl.lwjgl:lwjgl_util:2.9.4-nightly-20150209",
"org.lwjgl.lwjgl:lwjgl-platform:2.9.4-nightly-20150209",
"net.java.dev.jna:platform:3.4.0",
"com.ibm.icu:icu4j-core-mojang:51.2",
]
);
let names = merged
.libraries
.iter()
.map(|library| library.library.name.as_str())
.collect::<Vec<_>>();
for kept in [
"com.cleanroommc:cleanroom:0.6.11-alpha",
"org.lwjgl:lwjgl:3.4.1-unsafe",
"com.cleanroommc:lwjglxx:1.1.22",
"net.java.dev.jna:jna:5.19.1",
] {
assert!(names.contains(&kept), "{kept}");
}
assert_eq!(names.len(), 5);
}
#[test]
fn cleanroom_linked_merge_drops_legacy_conflicts_at_launch_normalization() {
let root = tempfile::tempdir().unwrap();
let mut merged = resolve_linked_fixture(
root.path(),
"1.12.2",
cleanroom_mixed_fixture(),
);
let removed = normalize_merged_loader_libraries(
ModLoader::Cleanroom,
&mut merged,
);
assert_eq!(
removed,
[
"org.lwjgl.lwjgl:lwjgl:2.9.4-nightly-20150209",
"org.lwjgl.lwjgl:lwjgl_util:2.9.4-nightly-20150209",
"org.lwjgl.lwjgl:lwjgl-platform:2.9.4-nightly-20150209",
"net.java.dev.jna:platform:3.4.0",
"com.ibm.icu:icu4j-core-mojang:51.2",
]
);
let names = merged
.libraries
.iter()
.map(|library| library.library.name.as_str())
.collect::<Vec<_>>();
for removed in &removed {
assert!(!names.contains(&removed.as_str()), "{removed}");
}
for kept in [
"com.cleanroommc:cleanroom:0.6.11-alpha",
"org.lwjgl:lwjgl:3.4.1-unsafe",
"com.cleanroommc:lwjglxx:1.1.22",
"net.java.dev.jna:jna:5.19.1",
] {
assert!(names.contains(&kept), "{kept}");
}
assert_eq!(names.len(), 5);
}
#[test]
fn non_cleanroom_loaders_leave_linked_libraries_untouched() {
// No `com.cleanroommc:cleanroom` coordinate: the launcher label alone
// decides, so other loaders (and vanilla) keep the legacy Mojang
// LWJGL 2/@JNA/ICU libraries exactly as the linked installation
// defines them.
let non_cleanroom_fixture = || {
json!([
{"name": "net.minecraftforge:forge:1.12.2-14.23.5.2864"},
{"name": "org.lwjgl.lwjgl:lwjgl:2.9.4-nightly-20150209"},
{"name": "org.lwjgl.lwjgl:lwjgl_util:2.9.4-nightly-20150209"},
{"name": "org.lwjgl.lwjgl:lwjgl-platform:2.9.4-nightly-20150209", "natives": {"windows": "natives-windows"}},
{"name": "net.java.dev.jna:platform:3.4.0"},
{"name": "com.ibm.icu:icu4j-core-mojang:51.2"}
])
};
for loader in [ModLoader::Vanilla, ModLoader::Forge] {
let root = tempfile::tempdir().unwrap();
let mut merged = resolve_linked_fixture(
root.path(),
"1.12.2",
non_cleanroom_fixture(),
);
let original = merged
.libraries
.iter()
.map(|library| library.library.name.clone())
.collect::<Vec<_>>();
let removed =
normalize_merged_loader_libraries(loader, &mut merged);
assert!(removed.is_empty(), "{loader:?}");
assert_eq!(
merged
.libraries
.iter()
.map(|library| library.library.name.clone())
.collect::<Vec<_>>(),
original
);
}
}
#[test]
fn cleanroom_library_coordinate_falls_back_when_vanilla_label_misses() {
// The frozen content-set loader label can mislabel a directly linked
// Cleanroom installation as vanilla (e.g. associations created before
// loader detection covered Cleanroom); the merged
// `com.cleanroommc:cleanroom` coordinate is then authoritative.
let root = tempfile::tempdir().unwrap();
let mut merged = resolve_linked_fixture(
root.path(),
"1.12.2",
cleanroom_mixed_fixture(),
);
let removed =
normalize_merged_loader_libraries(ModLoader::Vanilla, &mut merged);
assert_eq!(
removed,
[
"org.lwjgl.lwjgl:lwjgl:2.9.4-nightly-20150209",
"org.lwjgl.lwjgl:lwjgl_util:2.9.4-nightly-20150209",
"org.lwjgl.lwjgl:lwjgl-platform:2.9.4-nightly-20150209",
"net.java.dev.jna:platform:3.4.0",
"com.ibm.icu:icu4j-core-mojang:51.2",
]
);
let names = merged
.libraries
.iter()
.map(|library| library.library.name.as_str())
.collect::<Vec<_>>();
for name in &removed {
assert!(!names.contains(&name.as_str()), "{name}");
}
}
#[test]
fn non_cleanroom_loader_labels_never_fall_back_on_cleanroom_coordinate() {
// A Forge/other frozen label is authoritative even when the merged
// list happens to carry the Cleanroom coordinate: the fallback is
// strictly limited to the vanilla label.
for loader in [
ModLoader::Forge,
ModLoader::NeoForge,
ModLoader::Fabric,
ModLoader::OptiFine,
] {
let root = tempfile::tempdir().unwrap();
let mut merged = resolve_linked_fixture(
root.path(),
"1.12.2",
cleanroom_mixed_fixture(),
);
let original = merged
.libraries
.iter()
.map(|library| library.library.name.clone())
.collect::<Vec<_>>();
let removed =
normalize_merged_loader_libraries(loader, &mut merged);
assert!(removed.is_empty(), "{loader:?}");
assert_eq!(
merged
.libraries
.iter()
.map(|library| library.library.name.clone())
.collect::<Vec<_>>(),
original
);
}
}
#[test]
fn cleanroom_normalization_keeps_lwjgl3_line_and_lwjglxx() {
// Cleanroom normalization removes the LWJGL 2 carrier but keeps the
// Cleanroom LWJGL 3 line (main plus native classifier) and lwjglxx.
let root = tempfile::tempdir().unwrap();
let mut merged = resolve_linked_fixture(
root.path(),
"1.12.2",
json!([
{"name": "org.lwjgl.lwjgl:lwjgl-platform:2.9.4-nightly-20150209", "natives": {"windows": "natives-windows"}},
{"name": "org.lwjgl:lwjgl:3.4.1-unsafe"},
{"name": "org.lwjgl:lwjgl:3.4.1-unsafe", "natives": {"windows": "natives-windows"}},
{"name": "com.cleanroommc:lwjglxx:1.1.22"}
]),
);
let removed = normalize_merged_loader_libraries(
ModLoader::Cleanroom,
&mut merged,
);
assert_eq!(
removed,
["org.lwjgl.lwjgl:lwjgl-platform:2.9.4-nightly-20150209"]
);
let names = merged
.libraries
.iter()
.map(|library| library.library.name.as_str())
.collect::<Vec<_>>();
assert_eq!(
names,
[
"org.lwjgl:lwjgl:3.4.1-unsafe",
"org.lwjgl:lwjgl:3.4.1-unsafe",
"com.cleanroommc:lwjglxx:1.1.22",
]
);
}
#[test]
fn cleanroom_normalization_keeps_vanilla_lwjgl2_native_out_of_extraction() {
// The real 1.12.2 hazard: the vanilla LWJGL 2 carrier
// (`lwjgl-platform-2.9.4-nightly-20150209-natives-windows.jar`) ships
// a root-level `lwjgl.dll`, while Cleanroom's LWJGL 3 natives are
// nested (`windows/x64/org/lwjgl/lwjgl.dll`), so the two generations
// never overwrite each other — but the carrier's root-level
// `lwjgl.dll` is exactly the library name `System.loadLibrary("lwjgl")`
// (org.lwjgl.Sys) resolves first in the natives directory. Normalizing
// the merge for Cleanroom removes the carrier so the vanilla native
// never reaches the natives directory.
let root = tempfile::tempdir().unwrap();
let os = serde_json::to_value(Os::native().get_os()).unwrap();
let natives = |classifier: &str| {
let mut map = serde_json::Map::new();
map.insert(os.as_str().unwrap().to_string(), json!(classifier));
map
};
let lwjgl3_natives =
root.path().join("libraries/org/lwjgl/lwjgl/3.4.1-unsafe");
let lwjgl2_natives = root.path().join(
"libraries/org/lwjgl/lwjgl/lwjgl-platform/2.9.4-nightly-20150209",
);
std::fs::create_dir_all(&lwjgl3_natives).unwrap();
std::fs::create_dir_all(&lwjgl2_natives).unwrap();
write_native_jar(
&lwjgl3_natives.join("native3.jar"),
"windows/x64/org/lwjgl/lwjgl.dll",
b"LWJGL3",
);
write_native_jar(
&lwjgl2_natives.join("native2.jar"),
"lwjgl.dll",
b"LWJGL2",
);
let direct = DirectLinkedLaunch {
dot_minecraft: root.path().to_path_buf(),
launcher_root: None,
version_id: "1.12.2".to_string(),
version_json: None,
dialect: LinkedLauncherDialect::Hmcl,
game_dir_mode: None,
};
let lwjgl3_library: LinkedLibrary = serde_json::from_value(json!({
"name": "org.lwjgl:lwjgl:3.4.1-unsafe",
"natives": natives("natives-lwjgl3"),
"downloads": {"classifiers": {"natives-lwjgl3": {"path": "org/lwjgl/lwjgl/3.4.1-unsafe/native3.jar", "sha1": "", "size": 0, "url": ""}}}
}))
.unwrap();
let lwjgl2_library: LinkedLibrary = serde_json::from_value(json!({
"name": "org.lwjgl.lwjgl:lwjgl-platform:2.9.4-nightly-20150209",
"natives": natives("natives-lwjgl2"),
"downloads": {"classifiers": {"natives-lwjgl2": {"path": "org/lwjgl/lwjgl/lwjgl-platform/2.9.4-nightly-20150209/native2.jar", "sha1": "", "size": 0, "url": ""}}}
}))
.unwrap();
// Unnormalized merge order (loader libraries first, vanilla last):
// the LWJGL 2 carrier's root-level `lwjgl.dll` lands in the natives
// directory after the LWJGL 3 nested files were written.
let unnormalized = vec![lwjgl3_library.clone(), lwjgl2_library.clone()];
let shadowed = root.path().join("shadowed");
extract_linked_natives(
&direct,
&unnormalized,
&shadowed,
std::env::consts::ARCH,
true,
)
.unwrap();
assert_eq!(
std::fs::read(shadowed.join("lwjgl.dll")).unwrap(),
b"LWJGL2",
"root-level LWJGL 2 dll is what System.loadLibrary(\"lwjgl\") resolves"
);
assert_eq!(
std::fs::read(shadowed.join("windows/x64/org/lwjgl/lwjgl.dll"))
.unwrap(),
b"LWJGL3"
);
// Cleanroom normalization drops the carrier from the merged list, so
// the extraction pass that follows the normalize point in the launch
// pipeline never writes the vanilla native.
let mut merged = minimal_merged();
merged.libraries = vec![lwjgl3_library, lwjgl2_library];
normalize_merged_loader_libraries(ModLoader::Cleanroom, &mut merged);
let normalized = merged.libraries.iter().cloned().collect::<Vec<_>>();
assert_eq!(normalized.len(), 1);
let target = root.path().join("normalized");
extract_linked_natives(
&direct,
&normalized,
&target,
std::env::consts::ARCH,
true,
)
.unwrap();
assert!(
!target.join("lwjgl.dll").exists(),
"no vanilla LWJGL 2 dll may sit in the natives directory root"
);
assert_eq!(
std::fs::read(target.join("windows/x64/org/lwjgl/lwjgl.dll"))
.unwrap(),
b"LWJGL3"
);
}
fn write_native_jar(path: &std::path::Path, entry: &str, content: &[u8]) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let file = std::fs::File::create(path).unwrap();
let mut zip = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default();
zip.start_file(entry, options).unwrap();
zip.write_all(content).unwrap();
zip.finish().unwrap();
}
}