Files
Unlucky-Launcher/crates/cli/src/commands/snapshot.rs
T
AskaEth 0522a41a52
CI / fmt (rustfmt --check) (push) Waiting to run
CI / clippy (ubuntu-latest) (push) Waiting to run
CI / clippy (windows-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build release (ubuntu-latest) (push) Waiting to run
CI / build release (windows-latest) (push) Waiting to run
CI / runtime smoke (ubuntu x64, xvfb) (push) Waiting to run
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / update-e2e (windows x64) (push) Waiting to run
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Waiting to run
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Waiting to run
Guards / core 零 UI 守门 (push) Waiting to run
Guards / presenter 零 UI 守门 (push) Waiting to run
Guards / design 零第三方依赖守门 (push) Waiting to run
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
Release guard / 发布标记语义守门(push tag → 稳定版) (push) Waiting to run
Signing status(播报,不是门禁) / 判据自测(三行/退出码/变异重放/接线) (push) Waiting to run
Signing status(播报,不是门禁) / 签名就绪度(播报) (push) Waiting to run
Release / 选槽位(默认四个:Windows 两档 + Linux 两档) (push) Waiting to run
release: v0.2.5
2026-11-12 12:06:59 +08:00

332 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 实例 → [`LaunchRequest`] 的装配:launch 命令与 `--dry-run` 共用这一段。
//!
//! 全部走 core 的既有公共面(`game::resolve` / `arguments.expand` / `GameLayout` /
//! `launch::adapter::build_request`),CLI 不复制任何继承/参数展开逻辑。
use std::path::PathBuf;
use crate::context::Ctx;
use crate::error::CliError;
use unlucky_core::game::arguments::Environment;
use unlucky_core::game::{self, ProfileId, VersionJson};
use unlucky_core::launch::adapter::{
self, AdapterOptions, AdapterWarning, LibraryInput, SnapshotInput,
};
use unlucky_core::launch::request::{AuthPlaceholders, LaunchRequest, MemoryOptions};
/// 装配输入(来自命令行 + 实例档案)。
#[derive(Debug, Clone, Default)]
pub struct PlanInput {
/// 版本 id(实例绑定的 `versions/<id>/`)。
pub version_id: String,
/// 实例 id(写进会话索引;无档案时为空)。
pub instance_id: String,
/// 实例显示名。
pub instance_name: String,
/// 已派生的运行目录(D20 隔离覆盖链在 `super::launch` 求值完,这里只消费)。
pub game_dir: Option<PathBuf>,
/// Java 可执行文件覆盖。
pub java: Option<PathBuf>,
/// 离线账户名(无账户时生效;有账户时身份由 `auth` 决定)。
pub username: Option<String>,
/// 已解析好的账户占位(`None` = 退回 `username` 的离线占位)。
pub auth: Option<AuthPlaceholders>,
/// 最大堆 MB。
pub max_memory: Option<u32>,
}
/// 装配结果。
#[derive(Debug, Clone)]
pub struct Plan {
/// 实际使用的版本 id。
pub version_id: String,
/// 装配好的启动请求。
pub request: LaunchRequest,
/// 非致命告警(缺主类/缺资源索引等)。
pub warnings: Vec<String>,
}
/// 装配一个 [`LaunchRequest`]。
///
/// # Errors
///
/// 版本 JSON 缺失/非法、继承链断裂(缺父版本/成环)时返回用户错误。
pub fn prepare(ctx: &Ctx, input: &PlanInput) -> Result<Plan, CliError> {
let game = ctx.game();
let version_id = input.version_id.clone();
let manifest_path = game.version_json(&version_id);
let manifest = read_version_json(&manifest_path)?;
let source = |id: &str| -> Option<VersionJson> {
let path = game.version_json(id);
read_version_json(&path).ok()
};
let resolved = game::resolve(&source, &manifest).map_err(|error| {
CliError::user("inherit", format!("解析继承链失败:{error}"))
.with_hint(format!("检查 {}", manifest_path.display()))
})?;
let environment = Environment::host();
let expanded = resolved
.arguments
.as_ref()
.map(|arguments| arguments.expand(&environment))
.unwrap_or_default();
let mut libraries: Vec<LibraryInput> = Vec::new();
for library in resolved.libraries.as_deref().unwrap_or_default() {
if !library.applies(&environment) {
continue;
}
// 1.19+ 的 natives 是独立库条目(name 带分类器),必须进 classpath;
// ≤1.18 的 `natives` 表条目归 natives::collect,不进 classpath。
// Linux arm64:独立条目改写成 natives-linux-arm64(路径与展示名一起改)。
let arm64 = library.linux_arm64_native_name(&environment);
if arm64.is_none() && library.native_classifier(&environment).is_some() {
continue;
}
let (coordinate, name) = match arm64 {
Some(rewritten) => {
let Ok(coordinate) =
unlucky_core::game::library::MavenCoordinate::parse(&rewritten)
else {
continue;
};
(coordinate, rewritten)
}
None => {
let Ok(coordinate) = library.coordinate() else {
continue;
};
(coordinate, library.name.clone())
}
};
let path = if library.is_local() {
game.version_library(&version_id, &coordinate)
} else {
game.library(&coordinate)
};
libraries.push(LibraryInput::new(path, name));
}
// natives 分类器构件(≤1.18.2 才有):收集口径归 core,CLI 只搬运。
let natives = unlucky_core::launch::natives::collect(
&game,
&version_id,
resolved.libraries.as_deref().unwrap_or_default(),
&environment,
);
let jar_id = resolved.jar.clone().unwrap_or_else(|| version_id.clone());
let primary_jar = game.version_jar(&jar_id);
// 运行目录:调用方(`ull launch`)已按隔离覆盖链派生;`None` 兜底到游戏根
// (库/其它消费者直接构造 `PlanInput` 时保持旧行为)。
let game_dir = input
.game_dir
.clone()
.unwrap_or_else(|| ctx.data.game_home.clone());
let assets_root = game.assets_dir();
let assets_index = resolved
.assets
.clone()
.or_else(|| resolved.asset_index.as_ref().map(|index| index.id.clone()));
// 同 presenter:装配门限按**继承链的 MC 根版本**判(loader 实例的 version_id 带加载器后缀,
// 周快照的 6 字符形态判据在那儿失效,`--graphicsBackend` 会被静默跳过)。
let root_version = super::instance_launch::root_version_id(&game, &manifest);
let snapshot = SnapshotInput {
version_id: &version_id,
version_name: Some(&input.instance_name),
version_type: None,
game_version: Some(root_version.as_str()),
main_class: resolved.main_class.as_deref(),
jvm_arguments: &expanded.jvm,
game_arguments: &expanded.game,
legacy_arguments: resolved.minecraft_arguments.as_deref(),
libraries: &libraries,
natives: &natives,
primary_jar,
game_dir,
assets_index: assets_index.as_deref(),
natives_dir: game.natives_dir(&version_id),
library_dir: game.libraries_dir(),
assets_root: assets_root.clone(),
game_assets: assets_root,
};
// 实例级启动参数的三级回落(实例 → 全局 → 默认)。临时启动参数(`--max-memory` /
// `--java`)在 D20 的覆盖链里位于实例之上,所以这里先把它们叠上去再落位。
let mut effective = super::instance_launch::resolved(ctx, &input.instance_id);
if let Some(mb) = input.max_memory {
effective.memory_mb = mb;
}
if let Some(path) = &input.java {
effective.java_path = Some(path.clone());
}
let options = AdapterOptions {
java: super::java::selection(effective.java_path.as_deref()),
memory: MemoryOptions {
min_mb: None,
max_mb: Some(effective.memory_mb),
},
auth: input.auth.clone().unwrap_or_else(|| AuthPlaceholders {
player_name: input
.username
.clone()
.unwrap_or_else(|| "Player".to_string()),
uuid: ProfileId::generate().as_str().replace('-', ""),
user_type: "legacy".to_string(),
user_properties: "{}".to_string(),
..AuthPlaceholders::default()
}),
launcher_name: super::meta::LAUNCHER_NAME.to_string(),
launcher_version: env!("CARGO_PKG_VERSION").to_string(),
language: "zh-CN".to_string(),
..AdapterOptions::strict()
};
let output = adapter::build_request(&snapshot, options);
let warnings = output.warnings.iter().map(describe_warning).collect();
let mut request = output.request;
// 会话索引要用实例身份(adapter 默认拿版本 id/名)。
if !input.instance_id.is_empty() {
request.instance.id = input.instance_id.clone();
}
request.instance.name = input.instance_name.clone();
// 实例级启动参数(三级回落后的生效值)最后落位:user.* / memory / graphics_backend。
unlucky_core::launch::overrides::apply(&mut request, &effective);
Ok(Plan {
version_id,
request,
warnings,
})
}
fn read_version_json(path: &std::path::Path) -> Result<VersionJson, CliError> {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Err(CliError::user(
"version_missing",
format!("版本 JSON 不存在:{}", path.display()),
)
.with_hint("先准备好版本本体文件(下载装配属 P1 后续 mission)"));
}
Err(error) => return Err(CliError::io("读版本 JSON", path, &error)),
};
VersionJson::from_json_str(&text)
.map_err(|error| CliError::user("version_parse", format!("{}:{error}", path.display())))
}
/// 适配层告警 → 人读一行(`AdapterWarning` 没有 Display,这里集中翻译)。
fn describe_warning(warning: &AdapterWarning) -> String {
match warning {
AdapterWarning::MissingLibrary { name, path } => {
format!("缺少库文件 {name}({})", path.display())
}
AdapterWarning::MissingGameArguments => {
"版本 JSON 既没有 arguments.game 也没有 minecraftArguments".to_string()
}
AdapterWarning::MissingAssetsIndex => {
"版本 JSON 没有资源索引 id(${assets_index_name} 为空)".to_string()
}
AdapterWarning::MissingMainClass => "版本 JSON 没有 mainClass".to_string(),
AdapterWarning::MissingNativeJar { name, path } => {
format!("缺少 natives 构件 {name}({})", path.display())
}
}
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use super::*;
/// 一次性临时数据根(`--data-dir` 的等价物:custom 模式 → `<root>/.ULL` + `<root>/.minecraft`)。
struct TempDir(PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"ull-cli-snapshot-{tag}-{}-{seq}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).expect("建临时目录");
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn ctx_at(dir: &TempDir) -> Ctx {
let globals = crate::cli::Globals {
data_dir: Some(dir.path().to_path_buf()),
..crate::cli::Globals::default()
};
Ctx::resolve(&globals).expect("解析数据根")
}
fn write_version(ctx: &Ctx, id: &str, json: &str) {
let path = ctx.game().version_json(id);
std::fs::create_dir_all(path.parent().expect("版本目录")).expect("版本目录");
std::fs::write(&path, json).expect("写版本 JSON");
}
/// **周快照 + 加载器实例**(M157 r3 复审 P2-1):`prepare` 交给装配的游戏版本是
/// 继承链根的 `26w14a`,不是 `26w14a-fabric-0.16.5`。
///
/// 抓得住:`SnapshotInput.game_version` 回退成 `None`(装配用带后缀的 id 判门限,
/// `--graphicsBackend` 会被静默跳过,而 CLI 自己的门槛显示「可下发」)。
/// 抓不住:完整 argv(`crates/core/src/launch/overrides/tests.rs` 的周快照用例)。
#[test]
fn weekly_loader_instance_is_assembled_against_the_mc_root_version() {
let dir = TempDir::new("weekly");
let ctx = ctx_at(&dir);
write_version(
&ctx,
"26w14a",
r#"{"id":"26w14a","mainClass":"net.minecraft.client.main.Main",
"libraries":[{"name":"org.lwjgl:lwjgl-vulkan:3.4.3"}]}"#,
);
write_version(
&ctx,
"26w14a-fabric-0.16.5",
r#"{"id":"26w14a-fabric-0.16.5","inheritsFrom":"26w14a",
"mainClass":"net.fabricmc.loader.impl.launch.knot.KnotClient"}"#,
);
let plan = prepare(
&ctx,
&PlanInput {
version_id: "26w14a-fabric-0.16.5".to_string(),
instance_name: "周快照 Fabric".to_string(),
..PlanInput::default()
},
)
.expect("装配应成功");
assert_eq!(
plan.request.instance.version.raw(),
"26w14a",
"装配的游戏版本必须是继承链的 MC 根版本(CLI 门槛与拼装必须同一口径)"
);
assert!(plan.request.instance.version.is_weekly_snapshot());
assert!(plan.request.manifest.has_vulkan_backend);
}
}